Store Pickup Curbside
blockcommerceBuy 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
$pnpm dlx uipkge-ng@latest add store-pickup-curbside$npx uipkge-ng@latest add store-pickup-curbside$yarn dlx uipkge-ng@latest add store-pickup-curbside$bunx uipkge-ng@latest add store-pickup-curbsideManifest:
https://uipkge.dev/r/angular/store-pickup-curbside.jsonInstalls to:src/app/components/blocks/store-pickup-curbside/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
OrderIteminterface OrderItem {
id: string
name: string
sku: string
quantity: number
price: number
staged: boolean
category: string
}PickupOrderinterface 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
}ConfirmArrivalPayloadinterface ConfirmArrivalPayload {
orderId: string
spot: string
vehicleModel: string
plate: string
hazards: boolean
notes: string
}CreateOrderPayloadinterface CreateOrderPayload {
customerName: string
customerPhone: string
pickupMode: PickupMode
spotNumber: string
stagingAisle: string
stagingBin: string
itemSummary: string
orderTotal: string
}npm dependencies
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 & 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 < 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 & Contact</th> <th ui-table-head class="w-[180px] text-xs font-semibold">Order & Items</th> <th ui-table-head class="w-[220px] text-xs font-semibold">Pickup Mode & 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
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core' import { Car, Check, CheckCircle2, Layers, LucideAngularModule } from 'lucide-angular' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import type { OrderItem, PickupOrder } from './store-pickup-types' /** * "Inspect Order Details" dialog of the store-pickup-curbside block (React `OrderDetailDialog`): * customer + verification PIN strip, staging bin and vehicle / bay cards, the staged items * checklist, and a "Mark Delivered & Close" action for orders not yet handed over. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-order-detail-dialog, [ui-order-detail-dialog]', standalone: true, host: { class: 'contents' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiButtonComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, ], template: ` <ui-dialog [open]="open" (openChange)="openChange.emit($event)"> <ui-dialog-content class="sm:max-w-lg"> @if (selectedOrder; as order) { <div ui-dialog-header> <div class="flex items-start justify-between"> <div> <div class="flex items-center gap-2"> <h2 ui-dialog-title class="text-base font-bold">Order #{{ order.orderNumber }}</h2> <span ui-badge wrap [variant]="order.pickupMode === 'curbside' ? 'success' : 'info'" class="text-xs font-medium" > {{ order.pickupMode === 'curbside' ? 'Curbside Bay #' + order.spotNumber : 'In-Store Desk' }} </span> </div> <p ui-dialog-description class="mt-1 text-xs"> Placed at {{ order.placedAt }} · Staged at {{ order.readyAt }} </p> </div> </div> </div> <div class="space-y-4 py-2 text-xs"> <!-- Customer & Contact Strip --> <div class="border-border bg-muted/30 flex items-center justify-between rounded-lg border p-3"> <div class="flex items-center gap-2.5"> <span ui-avatar class="border-border size-9 border"> <span ui-avatar-fallback class="text-xs font-bold">{{ order.customerInitials }}</span> </span> <div> <div class="text-foreground text-xs font-semibold">{{ order.customerName }}</div> <div class="text-muted-foreground font-mono text-xs">{{ order.customerPhone }}</div> </div> </div> <div class="text-right"> <div class="text-muted-foreground text-xs">Pickup Verification PIN</div> <div class="text-primary font-mono text-sm font-bold tracking-widest"> {{ order.verificationCode }} </div> </div> </div> <!-- Staging & Bay Location Details --> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="border-border space-y-1 rounded-md border p-2.5"> <div class="text-muted-foreground flex items-center gap-1 text-xs font-medium"> <lucide-icon [img]="Layers" class="size-3.5" /> <span>Staging Bin Location</span> </div> <div class="text-foreground text-xs font-bold"> {{ order.stagingLocation.zone }} · <span class="text-primary font-mono">{{ order.stagingLocation.bin }}</span> </div> <div class="text-muted-foreground text-xs capitalize"> {{ order.stagingLocation.type }} unit storage </div> </div> <div class="border-border space-y-1 rounded-md border p-2.5"> <div class="text-muted-foreground flex items-center gap-1 text-xs font-medium"> <lucide-icon [img]="Car" class="size-3.5" /> <span>Vehicle & Bay</span> </div> @if (order.pickupMode === 'curbside' && order.vehicleDetails) { <div class="text-foreground truncate text-xs font-bold"> Spot #{{ order.spotNumber }} · {{ order.vehicleDetails.makeModel }} </div> <div class="text-muted-foreground font-mono text-xs">Plate: {{ order.vehicleDetails.plate }}</div> } @else { <div class="text-foreground text-xs font-bold">{{ order.pickupDeskBay || 'Pickup Counter' }}</div> <div class="text-muted-foreground text-xs"> Locker PIN: {{ order.stagingLocation.lockerPin || 'N/A' }} </div> } </div> </div> <!-- Staged Items Checklist --> <div class="space-y-2"> <div class="text-foreground flex items-center justify-between text-xs font-medium"> <span>Order Items Checklist ({{ order.items.length }} items)</span> <span class="text-muted-foreground">Total: \${{ order.orderTotal.toFixed(2) }}</span> </div> <div class="border-border max-h-40 space-y-1.5 overflow-y-auto rounded-md border p-2"> @for (item of order.items; track item.id) { <div class="bg-muted/40 flex items-center justify-between rounded p-2 text-xs"> <div class="flex items-center gap-2"> <lucide-icon [img]="CheckCircle2" class="text-success size-3.5 shrink-0" /> <div> <div class="text-foreground font-medium">{{ item.name }}</div> <div class="text-muted-foreground font-mono text-xs"> SKU: {{ item.sku }} · Qty: {{ item.quantity }} </div> </div> </div> <div class="text-foreground font-mono text-xs font-medium">\${{ lineTotal(item) }}</div> </div> } </div> </div> </div> <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button variant="outline" size="sm" class="text-xs" (click)="openChange.emit(false)"> Close </button> @if (order.status !== 'completed') { <button ui-button size="sm" class="bg-success hover:bg-success/90 gap-1.5 text-xs text-white" (click)="markDelivered.emit(order.id)" > <lucide-icon [img]="Check" class="size-3.5" /> <span>Mark Delivered & Close</span> </button> } </div> } </ui-dialog-content> </ui-dialog> `, }) export class UiOrderDetailDialogComponent { @Input() open = false @Input() selectedOrder: PickupOrder | null = null @Output() openChange = new EventEmitter<boolean>() @Output() markDelivered = new EventEmitter<string>() protected readonly Car = Car protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Layers = Layers protected lineTotal(item: OrderItem): string { return (item.price * item.quantity).toFixed(2) } }src/app/components/blocks/store-pickup-curbside/store-pickup-modals.component.ts17.8 kB
import { Component, DestroyRef, EventEmitter, Input, Output, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { Bell, Car, CheckCircle2, LucideAngularModule, MessageSquare, Send } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiButtonComponent } from '@/ui/button/button.component' import { UiDialogCloseComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import type { ConfirmArrivalPayload, CreateOrderPayload, NotifyChannel, PickupMode, PickupOrder, } from './store-pickup-types' /** * The store-pickup-curbside action dialogs (React `StorePickupModals`): the customer * "I'm Here" parking check-in, the notify-customer (SMS / push) dialog and the * stage-new-pickup-order form. Each dialog keeps its own form state; the parent owns the * open flags and applies the emitted payloads to the order list. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-store-pickup-modals, [ui-store-pickup-modals]', standalone: true, host: { class: 'contents' }, imports: [ LucideAngularModule, UiButtonComponent, UiDialogCloseComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, ], template: ` <!-- Modal 1: Customer "I'm Here" Parking Spot Check-in Dialog --> <ui-dialog [open]="checkInModalOpen" (openChange)="checkInModalOpenChange.emit($event)"> <ui-dialog-content class="sm:max-w-md"> <div ui-dialog-header> <div class="flex items-center gap-2"> <div class="border-warning/20 bg-warning/10 text-warning flex size-8 items-center justify-center rounded-lg border" > <lucide-icon [img]="Car" class="size-4" /> </div> <div> <h2 ui-dialog-title class="text-base font-bold">Customer Parking Arrival Check-In</h2> <p ui-dialog-description class="text-xs"> Check in customer arriving at the curbside pickup bay or simulate customer mobile app check-in. </p> </div> </div> </div> <div class="space-y-4 py-2 text-xs"> <!-- Select Order to Check In --> <div class="space-y-1.5"> <label class="text-foreground font-medium">Select Customer Order</label> <ui-select [value]="checkInOrderId()" (valueChange)="checkInOrderId.set($event)"> <button ui-select-trigger class="h-9 text-xs"> <ui-select-value placeholder="Select active order" /> </button> <ui-select-content> @for (o of activeOrders(); track o.id) { <ui-select-item [value]="o.id"> {{ o.customerName }} · #{{ o.orderNumber }} ({{ o.items.length }} items) </ui-select-item> } </ui-select-content> </ui-select> </div> <!-- Select Parking Spot --> <div class="space-y-1.5"> <label class="text-foreground font-medium">Curbside Parking Bay</label> <div class="grid grid-cols-6 gap-1.5"> @for (spot of spots; track spot) { <button type="button" [class]="spotClass(spot)" (click)="checkInSpot.set(spot)"> <span>Bay</span> <span class="text-sm font-bold">#{{ spot }}</span> </button> } </div> </div> <!-- Vehicle Information --> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="space-y-1.5"> <label class="text-foreground font-medium">Vehicle Make & Model</label> <ui-input [value]="checkInVehicleModel()" (valueChange)="checkInVehicleModel.set($event)" placeholder="e.g. Silver Tesla Model 3" class="h-8 text-xs" /> </div> <div class="space-y-1.5"> <label class="text-foreground font-medium">License Plate</label> <ui-input [value]="checkInPlate()" (valueChange)="checkInPlate.set($event)" placeholder="e.g. 7XYZ892" class="h-8 font-mono text-xs uppercase" /> </div> </div> <!-- Arrival Notes --> <div class="space-y-1.5"> <label class="text-foreground font-medium">Arrival Instructions & Bay Notes</label> <ui-input [value]="checkInNotes()" (valueChange)="checkInNotes.set($event)" placeholder="e.g. Trunk unlocked, hazard lights on" class="h-8 text-xs" /> </div> <!-- Hazard Lights Checkbox Simulation --> <div class="border-border bg-muted/40 flex items-center gap-2 rounded-md border p-2.5"> <input id="hazards-checkbox" [checked]="checkInHazards()" (change)="onHazardsChange($event)" type="checkbox" class="border-border text-primary focus:ring-primary size-4 rounded" /> <label for="hazards-checkbox" class="text-foreground cursor-pointer text-xs"> Vehicle hazard lights are actively flashing for runner identification </label> </div> </div> <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button ui-dialog-close variant="outline" size="sm" class="text-xs">Cancel</button> <button ui-button size="sm" class="bg-success hover:bg-success/90 gap-1.5 text-xs text-white" (click)="handleCheckInSubmit()" > <lucide-icon [img]="CheckCircle2" class="size-3.5" /> <span>Confirm Arrival & Alert Runner</span> </button> </div> </ui-dialog-content> </ui-dialog> <!-- Modal 2: Notify Customer Dialog --> <ui-dialog [open]="notifyModalOpen" (openChange)="notifyModalOpenChange.emit($event)"> <ui-dialog-content class="sm:max-w-md"> @if (selectedOrder; as order) { <div ui-dialog-header> <h2 ui-dialog-title class="text-base font-bold">Notify Customer</h2> <p ui-dialog-description class="text-xs"> Send real-time pickup status alert to {{ order.customerName }} ({{ order.customerPhone }}). </p> </div> <div class="space-y-4 py-2 text-xs"> <div class="space-y-1.5"> <label class="text-foreground font-medium">Notification Channel</label> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <button type="button" [class]="channelClass('sms')" (click)="notifyChannel.set('sms')"> <lucide-icon [img]="MessageSquare" class="size-3.5" /> <span>SMS Text Message</span> </button> <button type="button" [class]="channelClass('push')" (click)="notifyChannel.set('push')"> <lucide-icon [img]="Bell" class="size-3.5" /> <span>Mobile App Push</span> </button> </div> </div> <div class="border-border bg-muted/40 space-y-1.5 rounded-md border p-3 text-xs"> <div class="text-foreground flex items-center justify-between font-medium"> <span>Message Preview</span> <span class="text-muted-foreground font-mono">160 chars</span> </div> <p class="text-muted-foreground text-xs leading-relaxed"> "Hi {{ order.customerName }}, your Order #{{ order.orderNumber }} is ready at {{ storeName }}! Pull into Curbside Spot #{{ order.spotNumber || '1-6' }} and tap 'I'm Here' for runner delivery." </p> </div> </div> } <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button ui-dialog-close variant="outline" size="sm" class="text-xs">Cancel</button> <button ui-button size="sm" class="gap-1.5 text-xs" [disabled]="notifyMessageSent()" (click)="handleSendNotification()" > <lucide-icon [img]="Send" class="size-3.5" /> <span>{{ notifyMessageSent() ? 'Notification Sent!' : 'Send Alert' }}</span> </button> </div> </ui-dialog-content> </ui-dialog> <!-- Modal 3: New Pickup Order Staging Dialog --> <ui-dialog [open]="newOrderModalOpen" (openChange)="newOrderModalOpenChange.emit($event)"> <ui-dialog-content class="sm:max-w-md"> <div ui-dialog-header> <h2 ui-dialog-title class="text-base font-bold">Stage New Pickup Order</h2> <p ui-dialog-description class="text-xs"> Register an incoming online BOPIS order into retail staging shelves or lockers. </p> </div> <div class="space-y-3.5 py-2 text-xs"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="space-y-1.5"> <label class="text-foreground font-medium">Customer Name</label> <ui-input [value]="newCustomerName()" (valueChange)="newCustomerName.set($event)" placeholder="e.g. Alex Morgan" class="h-8 text-xs" /> </div> <div class="space-y-1.5"> <label class="text-foreground font-medium">Customer Phone</label> <ui-input [value]="newCustomerPhone()" (valueChange)="newCustomerPhone.set($event)" placeholder="e.g. (415) 555-0199" class="h-8 font-mono text-xs" /> </div> </div> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="space-y-1.5"> <label class="text-foreground font-medium">Pickup Mode</label> <ui-select [value]="newPickupMode()" (valueChange)="setNewPickupMode($event)"> <button ui-select-trigger class="h-8 text-xs"> <ui-select-value /> </button> <ui-select-content> <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="space-y-1.5"> <label class="text-foreground font-medium">Preferred Bay / Desk</label> <ui-select [value]="newSpotNumber()" (valueChange)="newSpotNumber.set($event)"> <button ui-select-trigger class="h-8 text-xs"> <ui-select-value /> </button> <ui-select-content> @for (spot of spots; track spot) { <ui-select-item [value]="spot">Bay #{{ spot }}</ui-select-item> } </ui-select-content> </ui-select> </div> </div> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="space-y-1.5"> <label class="text-foreground font-medium">Staging Aisle</label> <ui-input [value]="newStagingAisle()" (valueChange)="newStagingAisle.set($event)" placeholder="e.g. Aisle 01" class="h-8 text-xs" /> </div> <div class="space-y-1.5"> <label class="text-foreground font-medium">Staging Bin</label> <ui-input [value]="newStagingBin()" (valueChange)="newStagingBin.set($event)" placeholder="e.g. Bin B-05" class="h-8 text-xs" /> </div> </div> <div class="space-y-1.5"> <label class="text-foreground font-medium">Items Summary</label> <ui-input [value]="newItemSummary()" (valueChange)="newItemSummary.set($event)" placeholder="e.g. Noise Cancelling Earbuds, Travel Case" class="h-8 text-xs" /> </div> <div class="space-y-1.5"> <label class="text-foreground font-medium">Order Total ($)</label> <ui-input [value]="newOrderTotal()" (valueChange)="newOrderTotal.set($event)" placeholder="120.00" class="h-8 font-mono text-xs" /> </div> </div> <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button ui-dialog-close variant="outline" size="sm" class="text-xs">Cancel</button> <button ui-button size="sm" class="text-xs" [disabled]="!newCustomerName()" (click)="handleCreateOrder()"> <span>Stage & Assign Bin</span> </button> </div> </ui-dialog-content> </ui-dialog> `, }) export class UiStorePickupModalsComponent { @Input() checkInModalOpen = false @Output() checkInModalOpenChange = new EventEmitter<boolean>() @Input() notifyModalOpen = false @Output() notifyModalOpenChange = new EventEmitter<boolean>() @Input() newOrderModalOpen = false @Output() newOrderModalOpenChange = new EventEmitter<boolean>() @Input() set orders(value: PickupOrder[]) { this.activeOrders.set(value.filter((x) => x.status !== 'completed')) } @Input() selectedOrder: PickupOrder | null = null @Input() storeName = '' @Output() confirmArrival = new EventEmitter<ConfirmArrivalPayload>() @Output() sendNotification = new EventEmitter<NotifyChannel>() @Output() createOrder = new EventEmitter<CreateOrderPayload>() protected readonly Bell = Bell protected readonly Car = Car protected readonly CheckCircle2 = CheckCircle2 protected readonly MessageSquare = MessageSquare protected readonly Send = Send protected readonly spots = ['1', '2', '3', '4', '5', '6'] /** Orders offered in the check-in select (everything not yet handed over). */ protected readonly activeOrders = signal<PickupOrder[]>([]) // Check-in state readonly checkInOrderId = signal('order-3') readonly checkInSpot = signal('4') readonly checkInVehicleModel = signal('Silver Tesla Model 3') readonly checkInPlate = signal('7XYZ892') readonly checkInHazards = signal(true) readonly checkInNotes = signal('Parked in bay 4, trunk unlocked for contactless placement.') // Notify state readonly notifyChannel = signal<NotifyChannel>('sms') readonly notifyMessageSent = signal(false) // New Order state readonly newCustomerName = signal('') readonly newCustomerPhone = signal('') readonly newPickupMode = signal<PickupMode>('curbside') readonly newSpotNumber = signal('3') readonly newStagingAisle = signal('Aisle 01') readonly newStagingBin = signal('Bin A-12') readonly newItemSummary = signal('') readonly newOrderTotal = signal('120.00') private readonly timers = new Set<ReturnType<typeof setTimeout>>() constructor() { inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t))) } protected spotClass(spot: string): string { return cn( 'flex flex-col items-center justify-center rounded-md border py-2 text-xs font-semibold transition-colors duration-150', this.checkInSpot() === spot ? 'border-success bg-success/10 text-success ring-success/30 text-success ring-2' : 'border-border bg-card text-muted-foreground hover:border-foreground/20 hover:text-foreground', ) } protected channelClass(channel: NotifyChannel): string { return cn( 'flex items-center justify-center gap-1.5 rounded-md border py-2 text-xs font-semibold transition-colors', this.notifyChannel() === channel ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-card text-muted-foreground', ) } protected onHazardsChange(event: Event): void { this.checkInHazards.set((event.target as HTMLInputElement).checked) } protected setNewPickupMode(value: string): void { if (value === 'curbside' || value === 'in_store') this.newPickupMode.set(value) } handleCheckInSubmit(): void { this.confirmArrival.emit({ orderId: this.checkInOrderId(), spot: this.checkInSpot(), vehicleModel: this.checkInVehicleModel(), plate: this.checkInPlate(), hazards: this.checkInHazards(), notes: this.checkInNotes(), }) } handleSendNotification(): void { if (!this.selectedOrder) return this.notifyMessageSent.set(true) const channel = this.notifyChannel() const t = setTimeout(() => { this.timers.delete(t) this.sendNotification.emit(channel) this.notifyMessageSent.set(false) }, 1000) this.timers.add(t) } handleCreateOrder(): void { if (!this.newCustomerName()) return this.createOrder.emit({ customerName: this.newCustomerName(), customerPhone: this.newCustomerPhone(), pickupMode: this.newPickupMode(), spotNumber: this.newSpotNumber(), stagingAisle: this.newStagingAisle(), stagingBin: this.newStagingBin(), itemSummary: this.newItemSummary(), orderTotal: this.newOrderTotal(), }) this.newCustomerName.set('') this.newCustomerPhone.set('') this.newItemSummary.set('') } }src/app/components/blocks/store-pickup-curbside/store-pickup-types.ts1.9 kB
export type PickupMode = 'curbside' | 'in_store' export type OrderStatus = 'arrived' | 'en_route' | 'ready_staging' | 'completed' export interface OrderItem { id: string name: string sku: string quantity: number price: number staged: boolean category: string } export 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 } export type PickupModeFilter = 'all' | 'curbside' | 'in_store' export type PickupStatusFilter = 'all' | 'arrived' | 'en_route' | 'ready_staging' | 'completed' export type NotifyChannel = 'sms' | 'push' /** Inputs of `ui-store-pickup-curbside` (React `StorePickupCurbsideProps`). */ export interface StorePickupCurbsideProps { storeName?: string storeCode?: string initialModeFilter?: PickupModeFilter class?: string } /** Emitted by the check-in dialog's "Confirm Arrival & Alert Runner". */ export interface ConfirmArrivalPayload { orderId: string spot: string vehicleModel: string plate: string hazards: boolean notes: string } /** Emitted by the new-order dialog's "Stage & Assign Bin". */ export interface CreateOrderPayload { customerName: string customerPhone: string pickupMode: PickupMode spotNumber: string stagingAisle: string stagingBin: string itemSummary: string orderTotal: string }src/app/components/blocks/store-pickup-curbside/store-pickup-data.ts5.6 kB
import type { PickupOrder } from './store-pickup-types' export const INITIAL_ORDERS: PickupOrder[] = [ { id: 'order-1', orderNumber: 'BOPIS-84920', customerName: 'Elena Rostova', customerPhone: '(415) 555-0192', customerInitials: 'ER', pickupMode: 'curbside', spotNumber: 4, vehicleDetails: { makeModel: 'Silver Tesla Model 3', plate: '7XYZ892', hazardsFlashing: true, }, status: 'arrived', statusLabel: 'Customer Arrived · Waiting 2m', waitMinutes: 2, stagingLocation: { zone: 'Aisle 02', bin: 'Bin B-14', type: 'shelf', }, items: [ { id: 'item-1', name: 'Studio Pro Wireless Headphones', sku: 'AUD-902-BLK', quantity: 1, price: 249.0, staged: true, category: 'Audio', }, { id: 'item-2', name: 'Carbon Runner Wind Jacket', sku: 'APP-410-MED', quantity: 1, price: 100.0, staged: true, category: 'Apparel', }, ], orderTotal: 349.0, placedAt: '11:20 AM', readyAt: '11:35 AM', arrivedAt: '11:42 AM', verificationCode: '4920', assignedRunner: 'Jordan Miller', }, { id: 'order-2', orderNumber: 'BOPIS-84919', customerName: 'David Chen', customerPhone: '(415) 555-0841', customerInitials: 'DC', pickupMode: 'curbside', spotNumber: 2, vehicleDetails: { makeModel: 'Midnight Blue Rivian R1T', plate: '8ABC104', hazardsFlashing: true, }, status: 'arrived', statusLabel: 'Customer Arrived · Waiting 4m', waitMinutes: 4, stagingLocation: { zone: 'Aisle 01', bin: 'Bin A-08', type: 'bulky', }, items: [ { id: 'item-3', name: '4K Ultra HD Smart Monitor 27"', sku: 'DIS-270-4K', quantity: 1, price: 380.0, staged: true, category: 'Displays', }, { id: 'item-4', name: 'Ergonomic Desk Stand', sku: 'ACC-110-SLV', quantity: 1, price: 110.0, staged: true, category: 'Accessories', }, { id: 'item-5', name: '100W USB-C Thunderbolt Dock', sku: 'DOK-090-BLK', quantity: 1, price: 90.0, staged: true, category: 'Power', }, ], orderTotal: 580.0, placedAt: '10:45 AM', readyAt: '11:10 AM', arrivedAt: '11:40 AM', verificationCode: '8419', assignedRunner: 'Marcus Lee', }, { id: 'order-3', orderNumber: 'BOPIS-84918', customerName: 'Marcus Vance', customerPhone: '(415) 555-0329', customerInitials: 'MV', pickupMode: 'in_store', pickupDeskBay: 'Pickup Desk Bay 01', status: 'en_route', statusLabel: 'En Route · 6 min ETA', etaMinutes: 6, distanceMiles: 1.4, stagingLocation: { zone: 'Locker #12', bin: 'Bin L-12', lockerPin: '8491', type: 'locker', }, items: [ { id: 'item-6', name: 'Pro Max Mirrorless Camera Kit', sku: 'CAM-800-PRO', quantity: 1, price: 1299.0, staged: true, category: 'Photography', }, ], orderTotal: 1299.0, placedAt: '11:05 AM', readyAt: '11:28 AM', verificationCode: '9182', assignedRunner: 'Staging Auto-Lock', }, { id: 'order-4', orderNumber: 'BOPIS-84917', customerName: 'Aisha Patel', customerPhone: '(415) 555-0782', customerInitials: 'AP', pickupMode: 'in_store', pickupDeskBay: 'Pickup Desk Bay 03', status: 'ready_staging', statusLabel: 'Ready in Staging Locker #04', stagingLocation: { zone: 'Aisle 03', bin: 'Bin C-02', lockerPin: '3719', type: 'locker', }, items: [ { id: 'item-7', name: 'Organic Single-Origin Espresso Blend', sku: 'COF-012-ESP', quantity: 2, price: 38.0, staged: true, category: 'Beverage', }, { id: 'item-8', name: 'Precision Gooseneck Pour-Over Kettle', sku: 'KET-044-SS', quantity: 1, price: 74.5, staged: true, category: 'Kitchen', }, { id: 'item-9', name: 'Digital Barista Gram Scale', sku: 'SCL-019-DIG', quantity: 1, price: 30.0, staged: true, category: 'Kitchen', }, ], orderTotal: 142.5, placedAt: '10:15 AM', readyAt: '10:50 AM', verificationCode: '1742', assignedRunner: 'Desk Associate', }, { id: 'order-5', orderNumber: 'BOPIS-84916', customerName: 'Sarah Jenkins', customerPhone: '(415) 555-0453', customerInitials: 'SJ', pickupMode: 'curbside', spotNumber: 1, vehicleDetails: { makeModel: 'White Toyota RAV4', plate: '6MNO451', hazardsFlashing: false, }, status: 'en_route', statusLabel: 'En Route · 12 min ETA', etaMinutes: 12, distanceMiles: 3.8, stagingLocation: { zone: 'Aisle 02', bin: 'Bin B-03', type: 'shelf', }, items: [ { id: 'item-10', name: 'Ultra Hydration 20L Trail Backpack', sku: 'OUT-220-BLU', quantity: 1, price: 130.0, staged: true, category: 'Outdoors', }, { id: 'item-11', name: 'Carbon Composite Trekking Poles (Pair)', sku: 'OUT-771-CAR', quantity: 1, price: 80.0, staged: true, category: 'Outdoors', }, ], orderTotal: 210.0, placedAt: '11:15 AM', readyAt: '11:38 AM', verificationCode: '9160', assignedRunner: 'Unassigned', }, ]src/app/components/blocks/store-pickup-curbside/index.ts0.5 kB
export { UiStorePickupCurbsideComponent, type ConfirmArrivalPayload, type CreateOrderPayload, type NotifyChannel, type OrderItem, type OrderStatus, type PickupMode, type PickupModeFilter, type PickupOrder, type PickupStatusFilter, type StorePickupCurbsideProps, } from './store-pickup-curbside.component' export { UiOrderDetailDialogComponent } from './order-detail-dialog.component' export { UiStorePickupModalsComponent } from './store-pickup-modals.component' export { INITIAL_ORDERS } from './store-pickup-data'
Raw manifest:https://uipkge.dev/r/angular/store-pickup-curbside.json