Menu

Change language

GitHub repo

Store Pickup Curbside

blockcommerce

Buy Online Pick Up in Store (BOPIS) & curbside pickup coordination board with customer arrival parking check-in, real-time staging bin tracking, runner fulfillment dispatch, and SLA monitoring.

Installation

$npx uipkge-ng@latest add store-pickup-curbside
Manifest:https://uipkge.dev/r/angular/store-pickup-curbside.jsonInstalls to:src/app/components/blocks/store-pickup-curbside/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

OrderItem
interface OrderItem {
  id: string
  name: string
  sku: string
  quantity: number
  price: number
  staged: boolean
  category: string
}
PickupOrder
interface PickupOrder {
  id: string
  orderNumber: string
  customerName: string
  customerPhone: string
  customerInitials: string
  avatar?: string
  pickupMode: PickupMode
  spotNumber?: number
  vehicleDetails?: {
    makeModel: string
    plate: string
    hazardsFlashing?: boolean
  }
  pickupDeskBay?: string
  status: OrderStatus
  statusLabel: string
  etaMinutes?: number
  distanceMiles?: number
  waitMinutes?: number
  stagingLocation: {
    zone: string
    bin: string
    lockerPin?: string
    type: 'shelf' | 'locker' | 'bulky'
  }
  items: OrderItem[]
  orderTotal: number
  placedAt: string
  readyAt: string
  arrivedAt?: string
  verificationCode: string
  assignedRunner?: string
}
ConfirmArrivalPayload
interface ConfirmArrivalPayload {
  orderId: string
  spot: string
  vehicleModel: string
  plate: string
  hazards: boolean
  notes: string
}
CreateOrderPayload
interface CreateOrderPayload {
  customerName: string
  customerPhone: string
  pickupMode: PickupMode
  spotNumber: string
  stagingAisle: string
  stagingBin: string
  itemSummary: string
  orderTotal: string
}

Files installed (6)

  • src/app/components/blocks/store-pickup-curbside/store-pickup-curbside.component.ts38.4 kB
    import { Component, DestroyRef, Input, OnInit, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      Bell,
      Car,
      Check,
      CheckCircle2,
      Clock,
      Eye,
      Layers,
      LucideAngularModule,
      MapPin,
      MessageSquare,
      MoreHorizontal,
      Navigation,
      Package,
      PackageCheck,
      Phone,
      Plus,
      RefreshCw,
      Search,
      Store,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuLabelComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import { UiOrderDetailDialogComponent } from './order-detail-dialog.component'
    import { UiStorePickupModalsComponent } from './store-pickup-modals.component'
    import { INITIAL_ORDERS } from './store-pickup-data'
    import type {
      ConfirmArrivalPayload,
      CreateOrderPayload,
      NotifyChannel,
      OrderItem,
      OrderStatus,
      PickupMode,
      PickupModeFilter,
      PickupOrder,
      PickupStatusFilter,
      StorePickupCurbsideProps,
    } from './store-pickup-types'
    
    export type {
      ConfirmArrivalPayload,
      CreateOrderPayload,
      NotifyChannel,
      OrderItem,
      OrderStatus,
      PickupMode,
      PickupModeFilter,
      PickupOrder,
      PickupStatusFilter,
      StorePickupCurbsideProps,
    }
    
    /**
     * Buy Online Pick Up in Store (BOPIS) & curbside pickup coordination board (React
     * `StorePickupCurbside` 1:1): fulfillment health cards, a searchable / filterable active
     * pickup orders table with per-row fulfillment actions, the order detail dialog and the
     * check-in / notify / new-order dialogs, plus an inline feedback toast.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-store-pickup-curbside, [ui-store-pickup-curbside]',
      standalone: true,
      // React renders the root <div> itself: keep this wrapper out of the layout.
      host: { class: 'contents' },
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuLabelComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
        UiOrderDetailDialogComponent,
        UiStorePickupModalsComponent,
      ],
      template: `
        <div data-slot="store-pickup-curbside" [class]="rootClass">
          <!-- Live Toast Notification Banner -->
          @if (actionFeedbackToast(); as toast) {
            <div
              class="border-success/30 bg-success/10 text-success flex items-center justify-between rounded-md border px-4 py-2.5 text-xs font-medium shadow-xs transition-colors duration-200"
            >
              <div class="flex items-center gap-2">
                <lucide-icon [img]="CheckCircle2" class="text-success size-4 shrink-0" />
                <span>{{ toast }}</span>
              </div>
              <button
                aria-label="Dismiss notification"
                type="button"
                class="text-muted-foreground hover:text-foreground inline-flex size-4 items-center justify-center"
                (click)="actionFeedbackToast.set(null)"
              >
                <lucide-icon [img]="X" class="size-3.5" />
              </button>
            </div>
          }
    
          <!-- Header Section -->
          <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
            <div>
              <div class="flex flex-wrap items-center gap-2.5">
                <div
                  class="bg-primary/10 text-primary border-primary/20 flex size-8 items-center justify-center rounded-lg border"
                >
                  <lucide-icon [img]="Car" class="size-4" />
                </div>
                <h1 class="text-xl font-bold tracking-tight md:text-2xl">Store Pickup &amp; Curbside Coordination</h1>
                <span ui-badge wrap variant="outline" class="gap-1.5 font-medium">
                  <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                  <span class="text-xs">Live Staging Radar</span>
                </span>
              </div>
              <div class="text-muted-foreground mt-1.5 flex flex-wrap items-center gap-2 text-xs">
                <span class="text-foreground flex items-center gap-1 font-medium">
                  <lucide-icon [img]="MapPin" class="text-muted-foreground size-3.5" />
                  {{ storeName }}
                </span>
                <span class="font-mono text-xs">{{ storeCode }}</span>
                <span class="text-foreground font-medium tabular-nums">
                  {{ readyInStagingCount() }} Ready · {{ enRouteCount() }} En Route · {{ completedCount() }} Completed
                </span>
              </div>
            </div>
    
            <div class="flex flex-wrap items-center gap-2">
              <button
                ui-button
                variant="outline"
                size="sm"
                class="h-9 gap-1.5 text-xs shadow-xs"
                (click)="checkInModalOpen.set(true)"
              >
                <lucide-icon [img]="Car" class="text-warning size-3.5" />
                <span>Customer "I'm Here" Check-In</span>
              </button>
    
              <button ui-button size="sm" class="h-9 gap-1.5 text-xs shadow-xs" (click)="newOrderModalOpen.set(true)">
                <lucide-icon [img]="Plus" class="size-3.5" />
                <span>New Pickup Order</span>
              </button>
            </div>
          </div>
    
          <!-- 4 Fulfillment Health Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <!-- Card 1: Ready in Staging -->
            <div ui-card class="border-border shadow-xs">
              <div ui-card-header class="flex flex-row flex-wrap items-center justify-between gap-2 pb-2">
                <h3 ui-card-title class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                  Ready for Customer
                </h3>
                <div class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md">
                  <lucide-icon [img]="PackageCheck" class="text-foreground size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1.5">
                <div class="text-2xl font-bold tracking-tight tabular-nums">8 Orders in Staging</div>
                <div class="text-muted-foreground flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-xs">
                  <span>6 Bins · 2 Secured Lockers</span>
                  <span ui-badge wrap variant="success" class="h-5 px-1.5 text-xs font-medium">100% Pick Accuracy</span>
                </div>
              </div>
            </div>
    
            <!-- Card 2: Arrived in Curbside Bay (Amber Alert) -->
            <div ui-card class="border-warning/30 bg-warning/5 shadow-xs">
              <div ui-card-header class="flex flex-row flex-wrap items-center justify-between gap-2 pb-2">
                <h3
                  ui-card-title
                  class="text-warning text-warning min-w-0 text-xs font-semibold tracking-wider break-words uppercase"
                >
                  Customers in Parking Bays
                </h3>
                <div class="bg-warning/15 text-warning flex size-7 items-center justify-center rounded-md">
                  <lucide-icon [img]="Car" class="text-warning size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1.5">
                <div class="text-foreground dark:text-foreground text-2xl font-bold tracking-tight tabular-nums">
                  {{ arrivedCustomersCount() }} Customers Arrived
                </div>
                <div class="text-muted-foreground flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-xs">
                  <span class="text-warning/80 text-warning/80">Bay #4 (2m) · Bay #2 (4m)</span>
                  <span ui-badge wrap variant="warning" class="h-5 gap-1 px-1.5 text-xs font-medium">
                    <span class="bg-warning bg-warning size-1.5 rounded-full"></span>
                    Action Required
                  </span>
                </div>
              </div>
            </div>
    
            <!-- Card 3: Avg Fulfillment Time -->
            <div ui-card class="border-border shadow-xs">
              <div ui-card-header class="flex flex-row flex-wrap items-center justify-between gap-2 pb-2">
                <h3 ui-card-title class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                  Avg Fulfillment Time
                </h3>
                <div class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md">
                  <lucide-icon [img]="Clock" class="text-foreground size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1.5">
                <div class="text-2xl font-bold tracking-tight tabular-nums">1m 45s</div>
                <div class="text-muted-foreground flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-xs">
                  <span>Goal &lt; 3m 00s (-18s vs target)</span>
                  <span ui-badge wrap variant="info" class="h-5 px-1.5 text-xs font-medium">Fastest: 52s</span>
                </div>
              </div>
            </div>
    
            <!-- Card 4: Today's Completed Pickups -->
            <div ui-card class="border-border shadow-xs">
              <div ui-card-header class="flex flex-row flex-wrap items-center justify-between gap-2 pb-2">
                <h3 ui-card-title class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                  Today's Pickups
                </h3>
                <div class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md">
                  <lucide-icon [img]="CheckCircle2" class="text-foreground size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1.5">
                <div class="text-2xl font-bold tracking-tight tabular-nums">{{ completedCount() }} Orders</div>
                <div class="text-muted-foreground flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-xs">
                  <span>$5,240 Fulfilled Volume</span>
                  <span ui-badge wrap variant="success" class="h-5 px-1.5 text-xs font-medium">97.2% On-Time SLA</span>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Active Pickup Orders Board / Table Card -->
          <div ui-card class="border-border shadow-xs">
            <div ui-card-header class="space-y-4 pb-4">
              <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <h3 ui-card-title class="text-base font-semibold">Active Pickup Orders Board</h3>
                  <p ui-card-description class="text-xs">
                    Live queue of customers arriving curbside and checking in at in-store fulfillment stations.
                  </p>
                </div>
                <div class="flex items-center gap-2">
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="h-8 gap-1.5 text-xs shadow-xs"
                    (click)="handleRefresh()"
                  >
                    <lucide-icon [img]="RefreshCw" class="text-muted-foreground size-3" />
                    <span>Refresh</span>
                  </button>
                </div>
              </div>
    
              <!-- Filter & Search Controls -->
              <div class="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between">
                <div class="relative max-w-sm flex-1">
                  <!-- [class], not class=: a static class lands on both lucide-icon's host and its svg,
                       which would apply the absolute offset twice. -->
                  <lucide-icon
                    [img]="Search"
                    [class]="'text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2'"
                  />
                  <ui-input
                    [value]="searchQuery()"
                    (valueChange)="searchQuery.set($event)"
                    placeholder="Search customer, order #, spot, or items..."
                    class="h-8 pl-8 text-xs"
                  />
                  @if (searchQuery()) {
                    <button
                      type="button"
                      class="text-muted-foreground hover:text-foreground absolute top-1/2 right-2.5 -translate-y-1/2"
                      aria-label="Clear search"
                      (click)="searchQuery.set('')"
                    >
                      <lucide-icon [img]="X" class="size-3" />
                    </button>
                  }
                </div>
    
                <div class="flex flex-wrap items-center gap-2">
                  <div class="flex items-center gap-1.5">
                    <span class="text-muted-foreground text-xs">Mode:</span>
                    <ui-select [value]="selectedModeFilter()" (valueChange)="setModeFilter($event)">
                      <button ui-select-trigger class="h-8 w-32 text-xs">
                        <ui-select-value placeholder="Pickup Mode" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="all">All Modes</ui-select-item>
                        <ui-select-item value="curbside">Curbside Bay</ui-select-item>
                        <ui-select-item value="in_store">In-Store Desk</ui-select-item>
                      </ui-select-content>
                    </ui-select>
                  </div>
    
                  <div class="flex items-center gap-1.5">
                    <span class="text-muted-foreground text-xs">Status:</span>
                    <ui-select [value]="selectedStatusFilter()" (valueChange)="setStatusFilter($event)">
                      <button ui-select-trigger class="h-8 w-36 text-xs">
                        <ui-select-value placeholder="Status" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="all">All Statuses</ui-select-item>
                        <ui-select-item value="arrived">Customer Arrived</ui-select-item>
                        <ui-select-item value="en_route">En Route</ui-select-item>
                        <ui-select-item value="ready_staging">Ready in Staging</ui-select-item>
                        <ui-select-item value="completed">Completed</ui-select-item>
                      </ui-select-content>
                    </ui-select>
                  </div>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="p-0">
              <div class="overflow-x-auto">
                <div ui-table>
                  <thead ui-table-header class="bg-muted/40">
                    <tr ui-table-row>
                      <th ui-table-head class="w-[200px] text-xs font-semibold">Customer &amp; Contact</th>
                      <th ui-table-head class="w-[180px] text-xs font-semibold">Order &amp; Items</th>
                      <th ui-table-head class="w-[220px] text-xs font-semibold">Pickup Mode &amp; Bay</th>
                      <th ui-table-head class="w-[200px] text-xs font-semibold">Customer Status</th>
                      <th ui-table-head class="w-[160px] text-xs font-semibold">Staging Location</th>
                      <th ui-table-head class="w-[180px] text-right text-xs font-semibold">Fulfillment Actions</th>
                    </tr>
                  </thead>
                  <tbody ui-table-body>
                    @for (order of filteredOrders(); track order.id) {
                      <tr ui-table-row [class]="rowClass(order)">
                        <!-- Customer Name & Phone -->
                        <td ui-table-cell class="py-3">
                          <div class="flex items-center gap-2.5">
                            <span ui-avatar class="border-border size-8 rounded-full border">
                              <span ui-avatar-fallback class="bg-muted text-xs font-medium">{{
                                order.customerInitials
                              }}</span>
                            </span>
                            <div class="space-y-0.5">
                              <div class="text-foreground text-xs leading-tight font-semibold">
                                {{ order.customerName }}
                              </div>
                              <div class="text-muted-foreground flex items-center gap-1 font-mono text-xs tabular-nums">
                                <lucide-icon [img]="Phone" class="text-muted-foreground/70 size-3" />
                                {{ order.customerPhone }}
                              </div>
                            </div>
                          </div>
                        </td>
    
                        <!-- Order Number & Items Summary -->
                        <td ui-table-cell class="py-3">
                          <div class="space-y-1">
                            <div class="flex items-center gap-1.5">
                              <span class="text-foreground font-mono text-xs font-bold">#{{ order.orderNumber }}</span>
                              <span class="text-muted-foreground text-xs tabular-nums"
                                >(\${{ order.orderTotal.toFixed(2) }})</span
                              >
                            </div>
                            <div
                              class="text-muted-foreground max-w-[170px] min-w-0 truncate text-xs"
                              [title]="itemsTitle(order)"
                            >
                              {{ order.items.length }} {{ order.items.length === 1 ? 'item' : 'items' }}:
                              {{ itemNames(order) }}
                            </div>
                          </div>
                        </td>
    
                        <!-- Pickup Mode Badge -->
                        <td ui-table-cell class="py-3">
                          <div class="space-y-1">
                            @if (order.pickupMode === 'curbside') {
                              <span
                                ui-badge
                                wrap
                                variant="outline"
                                class="border-success/30 bg-success/10 text-success gap-1 font-medium"
                              >
                                <lucide-icon [img]="Car" class="text-success size-3 shrink-0" />
                                <span>Curbside Spot #{{ order.spotNumber }}</span>
                              </span>
                              @if (order.vehicleDetails; as vehicle) {
                                <div class="text-muted-foreground flex items-center gap-1 text-xs">
                                  <span class="max-w-[180px] truncate">{{ vehicle.makeModel }}</span>
                                  <span class="text-foreground font-mono font-medium">· {{ vehicle.plate }}</span>
                                </div>
                              }
                            } @else {
                              <span
                                ui-badge
                                wrap
                                variant="outline"
                                class="border-info/30 bg-info/10 text-info gap-1 font-medium"
                              >
                                <lucide-icon [img]="Store" class="text-info size-3 shrink-0" />
                                <span>In-Store Desk</span>
                              </span>
                              <div class="text-muted-foreground text-xs">
                                {{ order.pickupDeskBay || 'Front Pickup Counter' }}
                              </div>
                            }
                          </div>
                        </td>
    
                        <!-- Customer Status -->
                        <td ui-table-cell class="py-3">
                          <div class="space-y-1">
                            @switch (order.status) {
                              @case ('arrived') {
                                <span ui-badge wrap variant="warning" class="gap-1.5 px-2 py-0.5 font-medium">
                                  <span class="bg-warning size-1.5 rounded-full"></span>
                                  <lucide-icon [img]="Clock" class="size-3" />
                                  <span class="tabular-nums">Customer Arrived · Waiting {{ order.waitMinutes || 2 }}m</span>
                                </span>
                                <div class="text-muted-foreground flex items-center gap-1 text-xs">
                                  <span>Check-in: {{ order.arrivedAt || '11:40 AM' }}</span>
                                  @if (order.vehicleDetails?.hazardsFlashing) {
                                    <span class="text-warning font-medium">· Hazards On</span>
                                  }
                                </div>
                              }
                              @case ('en_route') {
                                <span ui-badge wrap variant="info" class="gap-1.5 px-2 py-0.5 font-medium">
                                  <lucide-icon [img]="Navigation" class="text-info size-3" />
                                  <span class="tabular-nums">En Route · {{ order.etaMinutes }} min ETA</span>
                                </span>
                                <div class="text-muted-foreground text-xs">
                                  <span class="tabular-nums">{{ order.distanceMiles }} miles away</span>
                                </div>
                              }
                              @case ('ready_staging') {
                                <span ui-badge wrap variant="secondary" class="gap-1.5 px-2 py-0.5 font-medium">
                                  <lucide-icon [img]="Package" class="size-3" />
                                  <span>Ready in Staging</span>
                                </span>
                                <div class="text-muted-foreground text-xs">Staged at {{ order.readyAt }}</div>
                              }
                              @default {
                                <span ui-badge wrap variant="success" class="gap-1.5 px-2 py-0.5 font-medium">
                                  <lucide-icon [img]="CheckCircle2" class="size-3" />
                                  <span>Delivered</span>
                                </span>
                                <div class="text-muted-foreground text-xs">Completed Handoff</div>
                              }
                            }
                          </div>
                        </td>
    
                        <!-- Staging Location -->
                        <td ui-table-cell class="py-3">
                          <div class="space-y-0.5">
                            <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold">
                              <lucide-icon [img]="Layers" class="text-muted-foreground size-3.5" />
                              <span>{{ order.stagingLocation.zone }}</span>
                              <span class="text-primary font-mono font-bold">{{ order.stagingLocation.bin }}</span>
                            </div>
                            @if (order.stagingLocation.lockerPin) {
                              <div class="text-muted-foreground font-mono text-xs">
                                PIN Code:
                                <span class="text-foreground font-bold">{{ order.stagingLocation.lockerPin }}</span>
                              </div>
                            } @else {
                              <div class="text-muted-foreground text-xs capitalize">
                                Type: {{ order.stagingLocation.type }} staging
                              </div>
                            }
                          </div>
                        </td>
    
                        <!-- Fulfillment Actions -->
                        <td ui-table-cell class="py-3 text-right">
                          <div class="flex items-center justify-end gap-1.5">
                            @if (order.status === 'arrived') {
                              <button
                                ui-button
                                size="sm"
                                class="bg-success hover:bg-success/90 bg-success dark:hover:bg-success/90 h-8 gap-1 px-2.5 text-xs font-medium text-white shadow-xs"
                                (click)="handleMarkDelivered(order.id)"
                              >
                                <lucide-icon [img]="Check" class="size-3.5" />
                                <span>Mark Delivered</span>
                              </button>
                            } @else if (order.status !== 'completed') {
                              <button
                                ui-button
                                variant="outline"
                                size="sm"
                                class="h-8 gap-1 px-2.5 text-xs shadow-xs"
                                (click)="handleNotify(order)"
                              >
                                <lucide-icon [img]="Bell" class="text-muted-foreground size-3" />
                                <span>Notify</span>
                              </button>
                            } @else {
                              <button ui-button variant="outline" size="sm" disabled class="h-8 text-xs opacity-60">
                                <lucide-icon [img]="CheckCircle2" class="text-success size-3" />
                                <span>Fulfilled</span>
                              </button>
                            }
    
                            <ui-dropdown-menu>
                              <button
                                ui-button
                                ui-dropdown-menu-trigger
                                variant="ghost"
                                size="icon"
                                class="text-muted-foreground hover:text-foreground size-8"
                              >
                                <lucide-icon [img]="MoreHorizontal" class="size-4" />
                                <span class="sr-only">Actions</span>
                              </button>
                              <ui-dropdown-menu-content align="end" class="w-48 text-xs">
                                <ui-dropdown-menu-label>Order #{{ order.orderNumber }}</ui-dropdown-menu-label>
                                <ui-dropdown-menu-separator />
                                <ui-dropdown-menu-item class="cursor-pointer gap-2" (select)="handleInspect(order)">
                                  <lucide-icon [img]="Eye" class="text-muted-foreground size-3.5" />
                                  <span>Inspect Order Details</span>
                                </ui-dropdown-menu-item>
                                <ui-dropdown-menu-item class="cursor-pointer gap-2" (select)="handleNotify(order)">
                                  <lucide-icon [img]="MessageSquare" class="text-muted-foreground size-3.5" />
                                  <span>Send SMS / Push Alert</span>
                                </ui-dropdown-menu-item>
                                <ui-dropdown-menu-separator />
                                @if (order.status !== 'completed') {
                                  <ui-dropdown-menu-item
                                    class="text-success cursor-pointer gap-2 font-medium"
                                    (select)="handleMarkDelivered(order.id)"
                                  >
                                    <lucide-icon [img]="CheckCircle2" class="size-3.5" />
                                    <span>Complete Handoff</span>
                                  </ui-dropdown-menu-item>
                                }
                              </ui-dropdown-menu-content>
                            </ui-dropdown-menu>
                          </div>
                        </td>
                      </tr>
                    }
    
                    @if (filteredOrders().length === 0) {
                      <tr ui-table-row>
                        <td ui-table-cell colspan="6" class="text-muted-foreground h-32 text-center text-xs">
                          No pickup orders match your search or filter criteria.
                        </td>
                      </tr>
                    }
                  </tbody>
                </div>
              </div>
            </div>
            <div
              ui-card-footer
              class="border-border text-muted-foreground flex items-center justify-between border-t px-6 py-3 text-xs"
            >
              <div>
                Showing <span class="text-foreground font-medium tabular-nums">{{ filteredOrders().length }}</span> of
                <span class="text-foreground font-medium tabular-nums">{{ orders().length }}</span> active pickup orders
              </div>
              <div class="flex items-center gap-3">
                <div class="flex items-center gap-1.5">
                  <span class="bg-success size-2 rounded-full"></span>
                  <span>Curbside Bays (Spots 1-6)</span>
                </div>
                <div class="flex items-center gap-1.5">
                  <span class="bg-info size-2 rounded-full"></span>
                  <span>In-Store Pickup Desk</span>
                </div>
              </div>
            </div>
          </div>
        </div>
    
        <!-- The dialogs portal to <body>. They sit outside the root so they are not space-y children: React renders nothing in place. -->
        <!-- Modal 1: Inspect Order Details Dialog -->
        <ui-order-detail-dialog
          [open]="inspectModalOpen()"
          (openChange)="inspectModalOpen.set($event)"
          [selectedOrder]="selectedOrder()"
          (markDelivered)="handleMarkDelivered($event)"
        />
    
        <!-- Action Modals (Check-In, Notify, New Order) -->
        <ui-store-pickup-modals
          [checkInModalOpen]="checkInModalOpen()"
          (checkInModalOpenChange)="checkInModalOpen.set($event)"
          [notifyModalOpen]="notifyModalOpen()"
          (notifyModalOpenChange)="notifyModalOpen.set($event)"
          [newOrderModalOpen]="newOrderModalOpen()"
          (newOrderModalOpenChange)="newOrderModalOpen.set($event)"
          [orders]="orders()"
          [selectedOrder]="selectedOrder()"
          [storeName]="storeName"
          (confirmArrival)="handleConfirmArrival($event)"
          (sendNotification)="handleSendNotification($event)"
          (createOrder)="handleCreateNewOrder($event)"
        />
      `,
    })
    export class UiStorePickupCurbsideComponent implements OnInit {
      @Input() storeName = 'Pasadena Flagship Store #02'
      @Input() storeCode = 'STORE-CA-02'
      @Input() initialModeFilter: PickupModeFilter = 'all'
      @Input('class') className?: string
    
      readonly orders = signal<PickupOrder[]>(INITIAL_ORDERS)
      readonly searchQuery = signal('')
      readonly selectedModeFilter = signal<PickupModeFilter>('all')
      readonly selectedStatusFilter = signal<PickupStatusFilter>('all')
    
      // Modals
      readonly checkInModalOpen = signal(false)
      readonly inspectModalOpen = signal(false)
      readonly notifyModalOpen = signal(false)
      readonly newOrderModalOpen = signal(false)
      readonly selectedOrder = signal<PickupOrder | null>(null)
      readonly actionFeedbackToast = signal<string | null>(null)
    
      // KPI calculations
      readonly readyInStagingCount = computed(() => this.orders().filter((o) => o.status !== 'completed').length)
      readonly arrivedCustomersCount = computed(() => this.orders().filter((o) => o.status === 'arrived').length)
      readonly enRouteCount = computed(() => this.orders().filter((o) => o.status === 'en_route').length)
      readonly completedCount = computed(() => this.orders().filter((o) => o.status === 'completed').length + 32)
    
      // Filtered orders list
      readonly filteredOrders = computed(() => {
        const query = this.searchQuery().trim().toLowerCase()
        const mode = this.selectedModeFilter()
        const status = this.selectedStatusFilter()
        return this.orders().filter((order) => {
          const matchesSearch =
            query === '' ||
            order.customerName.toLowerCase().includes(query) ||
            order.orderNumber.toLowerCase().includes(query) ||
            order.customerPhone.includes(query) ||
            order.stagingLocation.bin.toLowerCase().includes(query) ||
            (order.vehicleDetails?.makeModel.toLowerCase().includes(query) ?? false) ||
            (order.vehicleDetails?.plate.toLowerCase().includes(query) ?? false) ||
            order.items.some((i) => i.name.toLowerCase().includes(query))
    
          const matchesMode = mode === 'all' || order.pickupMode === mode
          const matchesStatus = status === 'all' || order.status === status
    
          return matchesSearch && matchesMode && matchesStatus
        })
      })
    
      protected readonly Bell = Bell
      protected readonly Car = Car
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Eye = Eye
      protected readonly Layers = Layers
      protected readonly MapPin = MapPin
      protected readonly MessageSquare = MessageSquare
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly Navigation = Navigation
      protected readonly Package = Package
      protected readonly PackageCheck = PackageCheck
      protected readonly Phone = Phone
      protected readonly Plus = Plus
      protected readonly RefreshCw = RefreshCw
      protected readonly Search = Search
      protected readonly Store = Store
      protected readonly X = X
    
      private readonly timers = new Set<ReturnType<typeof setTimeout>>()
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t)))
      }
    
      ngOnInit(): void {
        // React: useState(initialModeFilter) -- the prop only seeds the filter.
        this.selectedModeFilter.set(this.initialModeFilter)
      }
    
      protected get rootClass(): string {
        return cn('text-foreground w-full space-y-6', this.className)
      }
    
      protected rowClass(order: PickupOrder): string {
        return cn('transition-colors duration-150', order.status === 'arrived' && 'bg-warning/[0.04] bg-warning/[0.08]')
      }
    
      protected itemsTitle(order: PickupOrder): string {
        return order.items.map((i) => `${i.quantity}x ${i.name}`).join(', ')
      }
    
      protected itemNames(order: PickupOrder): string {
        return order.items.map((i) => i.name).join(', ')
      }
    
      protected setModeFilter(value: string): void {
        if (value === 'all' || value === 'curbside' || value === 'in_store') this.selectedModeFilter.set(value)
      }
    
      protected setStatusFilter(value: string): void {
        if (
          value === 'all' ||
          value === 'arrived' ||
          value === 'en_route' ||
          value === 'ready_staging' ||
          value === 'completed'
        )
          this.selectedStatusFilter.set(value)
      }
    
      /** Toast feedback helper: shows `msg` for 3.5s unless a newer message replaced it. */
      triggerToast(msg: string): void {
        this.actionFeedbackToast.set(msg)
        const t = setTimeout(() => {
          this.timers.delete(t)
          if (this.actionFeedbackToast() === msg) this.actionFeedbackToast.set(null)
        }, 3500)
        this.timers.add(t)
      }
    
      handleInspect(order: PickupOrder): void {
        this.selectedOrder.set(order)
        this.inspectModalOpen.set(true)
      }
    
      handleNotify(order: PickupOrder): void {
        this.selectedOrder.set(order)
        this.notifyModalOpen.set(true)
      }
    
      handleSendNotification(channel: NotifyChannel): void {
        const selected = this.selectedOrder()
        if (!selected) return
        this.notifyModalOpen.set(false)
        this.triggerToast(`Sent arrival notification to ${selected.customerName} via ${channel.toUpperCase()}`)
      }
    
      handleMarkDelivered(orderId: string): void {
        const target = this.orders().find((o) => o.id === orderId)
        this.orders.update((prev) =>
          prev.map((o) => (o.id === orderId ? { ...o, status: 'completed', statusLabel: 'Delivered & Handed Over' } : o)),
        )
        if (target) this.triggerToast(`Order #${target.orderNumber} successfully delivered to ${target.customerName}!`)
        this.inspectModalOpen.set(false)
      }
    
      handleConfirmArrival(payload: ConfirmArrivalPayload): void {
        const target = this.orders().find((o) => o.id === payload.orderId)
        if (!target) return
        this.orders.update((prev) =>
          prev.map((o) =>
            o.id === payload.orderId
              ? {
                  ...o,
                  status: 'arrived',
                  pickupMode: 'curbside',
                  spotNumber: parseInt(payload.spot, 10) || 4,
                  waitMinutes: 1,
                  statusLabel: 'Customer Arrived · Waiting 1m',
                  vehicleDetails: {
                    makeModel: payload.vehicleModel || 'Vehicle Unspecified',
                    plate: payload.plate || 'N/A',
                    hazardsFlashing: payload.hazards,
                  },
                  arrivedAt: 'Just now',
                }
              : o,
          ),
        )
        this.checkInModalOpen.set(false)
        this.triggerToast(`Customer ${target.customerName} checked into Spot #${payload.spot}! Runner alerted.`)
      }
    
      handleCreateNewOrder(payload: CreateOrderPayload): void {
        const idNum = Math.floor(10000 + Math.random() * 90000)
        const newOrder: PickupOrder = {
          id: `order-${Date.now()}`,
          orderNumber: `BOPIS-${idNum}`,
          customerName: payload.customerName,
          customerPhone: payload.customerPhone || '(415) 555-0100',
          customerInitials: payload.customerName
            .split(' ')
            .map((n) => n[0])
            .join('')
            .toUpperCase()
            .slice(0, 2),
          pickupMode: payload.pickupMode,
          spotNumber: payload.pickupMode === 'curbside' ? parseInt(payload.spotNumber, 10) || 3 : undefined,
          pickupDeskBay: payload.pickupMode === 'in_store' ? 'Pickup Desk Bay 02' : undefined,
          vehicleDetails:
            payload.pickupMode === 'curbside'
              ? {
                  makeModel: 'Customer Vehicle',
                  plate: 'PENDING',
                  hazardsFlashing: false,
                }
              : undefined,
          status: 'ready_staging',
          statusLabel: `Ready in ${payload.stagingAisle}`,
          stagingLocation: {
            zone: payload.stagingAisle,
            bin: payload.stagingBin,
            type: payload.pickupMode === 'in_store' ? 'locker' : 'shelf',
          },
          items: [
            {
              id: `item-${Date.now()}`,
              name: payload.itemSummary || 'Selected Retail Merchandise',
              sku: `SKU-${Math.floor(100 + Math.random() * 900)}`,
              quantity: 1,
              price: parseFloat(payload.orderTotal) || 99.0,
              staged: true,
              category: 'General',
            },
          ],
          orderTotal: parseFloat(payload.orderTotal) || 99.0,
          placedAt: '11:45 AM',
          readyAt: 'Just now',
          verificationCode: `${Math.floor(1000 + Math.random() * 9000)}`,
          assignedRunner: 'Pending Handoff',
        }
    
        this.orders.update((prev) => [newOrder, ...prev])
        this.newOrderModalOpen.set(false)
        this.triggerToast(`New Order #${newOrder.orderNumber} staged in ${newOrder.stagingLocation.bin}!`)
      }
    
      handleRefresh(): void {
        this.triggerToast('Refreshed pickup staging queue & curbside telemetry')
      }
    }
    
  • src/app/components/blocks/store-pickup-curbside/order-detail-dialog.component.ts7.8 kB
  • src/app/components/blocks/store-pickup-curbside/store-pickup-modals.component.ts17.8 kB
  • src/app/components/blocks/store-pickup-curbside/store-pickup-types.ts1.9 kB
  • src/app/components/blocks/store-pickup-curbside/store-pickup-data.ts5.6 kB
  • src/app/components/blocks/store-pickup-curbside/index.ts0.5 kB

Raw manifest:https://uipkge.dev/r/angular/store-pickup-curbside.json