Blocks

Pickup Handoff Block

Commerce UI

A mobile-first post-purchase flow for local pickup arrival, order verification, item readiness, substitutions, and contactless handoff.

Commerce

Pickup handoff flow

Copy this into grocery, retail, marketplace, rental, pharmacy, field-service, or BOPIS apps that need a focused customer pickup experience. Replace the sample order with your fulfilment state, verification rules, item exceptions, and handoff events.

1200px

<script setup>
import { computed, ref } from 'vue';
import { DomButton, DomNativeSelect, DomTabs, DomTextInput, DomToggle } from '@getdom/studio/vue';

const tabs = [
	{ key: 'arrival', label: 'Arrival' },
	{ key: 'items', label: 'Items' },
	{ key: 'handoff', label: 'Handoff' },
];

const arrivalMethods = [
	{ label: 'Curbside pickup', value: 'Curbside pickup' },
	{ label: 'Walk-up counter', value: 'Walk-up counter' },
	{ label: 'Drive-through lane', value: 'Drive-through lane' },
];

const bayOptions = [
	{ label: 'Bay B2', value: 'B2' },
	{ label: 'Bay B3', value: 'B3' },
	{ label: 'Front entrance', value: 'Front entrance' },
	{ label: 'Inside counter', value: 'Inside counter' },
];

const initialItems = [
	{
		id: 'cold-bag',
		name: 'Chilled meal kit',
		detail: 'Dinner for two / cold chain',
		quantity: 1,
		ready: true,
		status: 'Packed',
		note: 'Keep below 5 C until handoff.',
	},
	{
		id: 'oat-milk',
		name: 'Oat milk multipack',
		detail: '6 x 1L cartons',
		quantity: 2,
		ready: false,
		status: 'Substitution ready',
		note: 'Original unavailable. Barista blend staged.',
	},
	{
		id: 'id-check',
		name: 'Sparkling mixer case',
		detail: 'Restricted item / ID required',
		quantity: 1,
		ready: true,
		status: 'ID check',
		note: 'Verify age before loading.',
	},
];

const timeline = [
	{ label: 'Customer checked in', detail: 'Curbside pickup at Bay B2.', time: '16:34' },
	{ label: 'Cold bag staged', detail: 'Temperature label scanned by fulfilment.', time: '16:29' },
	{ label: 'Substitution offered', detail: 'Barista blend approved by shopper.', time: '16:21' },
];

const activeTab = ref('arrival');
const arrivalMethod = ref('Curbside pickup');
const bay = ref('B2');
const pickupCode = ref('4821');
const customerMessage = ref('Blue hatchback. Trunk is open for contactless loading.');
const items = ref(initialItems.map((item) => ({ ...item })));
const contactless = ref(true);
const substitutionsAccepted = ref(true);
const idChecked = ref(false);
const handoffComplete = ref(false);
const lastAction = ref('');

const allItemsReady = computed(() => items.value.every((item) => item.ready));
const verifiedCode = computed(() => pickupCode.value.trim() === '4821');
const hasRestrictedItem = computed(() => items.value.some((item) => item.status === 'ID check'));
const requiredChecks = computed(() => [
	{
		label: 'Arrival location shared',
		detail: `${arrivalMethod.value} / ${bay.value}`,
		passed: Boolean(arrivalMethod.value && bay.value),
	},
	{
		label: 'Pickup code verified',
		detail: verifiedCode.value ? 'Code 4821 matches this order' : 'Ask customer for the 4 digit code',
		passed: verifiedCode.value,
	},
	{
		label: 'All items staged',
		detail: `${items.value.filter((item) => item.ready).length} of ${items.value.length} ready`,
		passed: allItemsReady.value,
	},
	{
		label: 'Restricted item check',
		detail: hasRestrictedItem.value ? 'Age or identity check required' : 'No restricted items',
		passed: !hasRestrictedItem.value || idChecked.value,
	},
	{
		label: 'Substitution decision saved',
		detail: substitutionsAccepted.value ? 'Customer accepts approved substitutions' : 'Hold substitution for staff review',
		passed: substitutionsAccepted.value,
	},
]);
const readyToComplete = computed(() => requiredChecks.value.every((check) => check.passed));
const progressPercent = computed(() => Math.round((requiredChecks.value.filter((check) => check.passed).length / requiredChecks.value.length) * 100));
const primaryLabel = computed(() => {
	if (handoffComplete.value) return 'Handoff completed';
	if (!verifiedCode.value) return 'Verify pickup code';
	if (!allItemsReady.value) return 'Stage remaining items';
	if (hasRestrictedItem.value && !idChecked.value) return 'Confirm ID check';
	return 'Complete handoff';
});
const statusLabel = computed(() => {
	if (handoffComplete.value) return 'Complete';
	if (readyToComplete.value) return 'Ready for loading';
	return 'Needs attention';
});

function toggleItemReady(item) {
	item.ready = !item.ready;
	lastAction.value = item.ready ? `${item.name} marked ready` : `${item.name} moved back to staging`;
	handoffComplete.value = false;
}

function completeHandoff() {
	if (!readyToComplete.value) {
		activeTab.value = !verifiedCode.value ? 'arrival' : !allItemsReady.value ? 'items' : 'handoff';
		lastAction.value = 'Review the highlighted checks before completing pickup.';
		return;
	}

	handoffComplete.value = true;
	activeTab.value = 'handoff';
	lastAction.value = 'Pickup completed and order timeline updated.';
}
</script>

<template>
	<div class="w-full bg-secondary/40 px-3 py-5 text-fg sm:px-6 lg:px-8">
		<div class="mx-auto grid max-w-6xl gap-5 lg:grid-cols-[minmax(22rem,28rem)_minmax(0,1fr)] lg:items-start">
			<section class="mx-auto w-full max-w-[28rem] overflow-hidden rounded-[2rem] border border-border bg-background shadow-2xl shadow-black/10">
				<header class="skin-raised border-b border-border px-4 py-4">
					<div class="flex items-start justify-between gap-3">
						<div class="min-w-0">
							<p class="text-xs font-semibold uppercase tracking-[0.16em] text-muted-fg">Pickup order</p>
							<h3 class="mt-1 text-2xl font-semibold tracking-tight">PU-7831</h3>
							<p class="mt-1 text-sm text-muted-fg">Maya Chen / Soho Market</p>
						</div>
						<span
							class="shrink-0 rounded-full px-3 py-1 text-xs font-semibold"
							:class="readyToComplete ? 'bg-success/15 text-success' : 'bg-warning/15 text-warning'"
						>
							{{ statusLabel }}
						</span>
					</div>

					<div class="mt-4">
						<div class="flex items-center justify-between text-xs font-semibold text-muted-fg">
							<span>16:30-17:00</span>
							<span>{{ progressPercent }}% ready</span>
						</div>
						<div class="mt-2 h-2 overflow-hidden rounded-full bg-secondary">
							<div class="h-full rounded-full bg-primary transition-all" :style="{ width: `${progressPercent}%` }"></div>
						</div>
					</div>
				</header>

				<div class="p-4">
					<DomTabs v-model="activeTab" :tabs="tabs" />

					<section v-if="activeTab === 'arrival'" class="mt-4 grid gap-4">
						<div class="grid gap-3 rounded-2xl border border-border bg-background p-4">
							<div class="flex items-start gap-3">
								<div class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
									<svg viewBox="0 0 24 24" class="size-5" fill="none" aria-hidden="true">
										<path d="M12 21s7-5.2 7-11A7 7 0 1 0 5 10c0 5.8 7 11 7 11Zm0-8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
									</svg>
								</div>
								<div class="min-w-0">
									<h4 class="font-semibold">Customer arrived</h4>
									<p class="mt-1 text-sm leading-6 text-muted-fg">Staff can stage the order from the selected bay and loading preference.</p>
								</div>
							</div>
							<div class="grid gap-3 sm:grid-cols-2">
								<DomNativeSelect v-model="arrivalMethod" label="Arrival method" :options="arrivalMethods" />
								<DomNativeSelect v-model="bay" label="Pickup spot" :options="bayOptions" />
							</div>
						</div>

						<div class="grid gap-3 rounded-2xl border border-border bg-secondary/50 p-4">
							<DomTextInput v-model="pickupCode" label="Pickup code" inputmode="numeric" maxlength="4" placeholder="4 digit code" />
							<p class="text-sm leading-6 text-muted-fg">
								{{ verifiedCode ? 'Code verified for this order.' : 'The expected test code is 4821. In production, validate this server-side.' }}
							</p>
						</div>

						<div class="rounded-2xl border border-border bg-background p-4">
							<p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Customer note</p>
							<p class="mt-2 text-sm leading-6">{{ customerMessage }}</p>
						</div>
					</section>

					<section v-else-if="activeTab === 'items'" class="mt-4 grid gap-3">
						<button
							v-for="item in items"
							:key="item.id"
							type="button"
							class="grid w-full gap-3 rounded-2xl border border-border bg-background p-4 text-left transition hover:bg-secondary/60"
							:class="item.ready ? 'border-success/30' : 'border-warning/40'"
							@click="toggleItemReady(item)"
						>
							<span class="flex items-start justify-between gap-3">
								<span class="min-w-0">
									<span class="block font-semibold">{{ item.name }}</span>
									<span class="mt-1 block text-sm leading-6 text-muted-fg">{{ item.detail }} / Qty {{ item.quantity }}</span>
								</span>
								<span
									class="shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold"
									:class="item.ready ? 'bg-success/15 text-success' : 'bg-warning/15 text-warning'"
								>
									{{ item.ready ? 'Ready' : 'Stage' }}
								</span>
							</span>
							<span class="rounded-xl bg-secondary/70 px-3 py-2 text-sm leading-6 text-muted-fg">{{ item.note }}</span>
						</button>
					</section>

					<section v-else class="mt-4 grid gap-4">
						<div class="grid gap-3 rounded-2xl border border-border bg-background p-4">
							<div class="flex items-center justify-between gap-4">
								<div>
									<h4 class="font-semibold">Contactless loading</h4>
									<p class="mt-1 text-sm leading-6 text-muted-fg">Staff loads the order without handling the device.</p>
								</div>
								<DomToggle v-model="contactless" aria-label="Use contactless loading" />
							</div>
							<div class="flex items-center justify-between gap-4">
								<div>
									<h4 class="font-semibold">Accept substitutions</h4>
									<p class="mt-1 text-sm leading-6 text-muted-fg">Customer approved the staged substitute item.</p>
								</div>
								<DomToggle v-model="substitutionsAccepted" aria-label="Accept substitutions" />
							</div>
							<div class="flex items-center justify-between gap-4">
								<div>
									<h4 class="font-semibold">ID checked</h4>
									<p class="mt-1 text-sm leading-6 text-muted-fg">Required because this order has a restricted item.</p>
								</div>
								<DomToggle v-model="idChecked" aria-label="Confirm ID check" />
							</div>
						</div>

						<div class="rounded-2xl border border-border bg-secondary/50 p-4">
							<p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Recent events</p>
							<div class="mt-3 grid gap-3">
								<div v-for="event in timeline" :key="event.label" class="grid grid-cols-[3.5rem_minmax(0,1fr)] gap-3 text-sm">
									<span class="font-semibold text-muted-fg">{{ event.time }}</span>
									<span>
										<span class="block font-medium">{{ event.label }}</span>
										<span class="mt-0.5 block leading-5 text-muted-fg">{{ event.detail }}</span>
									</span>
								</div>
							</div>
						</div>
					</section>
				</div>

				<footer class="sticky bottom-0 border-t border-border bg-background/95 p-4 shadow-[0_-18px_45px_rgba(15,23,42,0.12)] backdrop-blur">
					<div v-if="lastAction" class="mb-3 rounded-xl bg-secondary/70 px-3 py-2 text-sm text-muted-fg">
						{{ lastAction }}
					</div>
					<div class="grid gap-3">
						<div class="grid gap-2">
							<div v-for="check in requiredChecks" :key="check.label" class="flex items-start gap-3 text-sm">
								<span
									class="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-bold"
									:class="check.passed ? 'bg-success/15 text-success' : 'bg-warning/15 text-warning'"
								>
									{{ check.passed ? 'OK' : '!' }}
								</span>
								<span class="min-w-0">
									<span class="block font-medium">{{ check.label }}</span>
									<span class="block leading-5 text-muted-fg">{{ check.detail }}</span>
								</span>
							</div>
						</div>
						<DomButton class="w-full" :disabled="handoffComplete" @click="completeHandoff">
							{{ primaryLabel }}
						</DomButton>
					</div>
				</footer>
			</section>

			<aside class="grid gap-4">
				<section class="overflow-hidden rounded-3xl border border-border bg-background shadow-2xl shadow-black/10">
					<div class="border-b border-border skin-raised p-5">
						<p class="text-xs font-semibold uppercase tracking-[0.16em] text-muted-fg">Handoff snapshot</p>
						<h3 class="mt-1 text-xl font-semibold tracking-tight">Order is {{ statusLabel.toLowerCase() }}</h3>
						<p class="mt-2 text-sm leading-6 text-muted-fg">A companion summary for larger screens, store displays, or embedded staff workflows.</p>
					</div>

					<div class="grid gap-4 p-5">
						<div class="grid gap-3 rounded-2xl border border-border bg-secondary/40 p-4">
							<div class="flex items-center justify-between gap-3">
								<span class="text-sm font-medium text-muted-fg">Customer</span>
								<span class="font-semibold">Maya Chen</span>
							</div>
							<div class="flex items-center justify-between gap-3">
								<span class="text-sm font-medium text-muted-fg">Pickup spot</span>
								<span class="font-semibold">{{ bay }}</span>
							</div>
							<div class="flex items-center justify-between gap-3">
								<span class="text-sm font-medium text-muted-fg">Method</span>
								<span class="font-semibold">{{ arrivalMethod }}</span>
							</div>
						</div>

						<div class="grid gap-3">
							<div v-for="check in requiredChecks" :key="`desktop-${check.label}`" class="rounded-2xl border border-border bg-background p-4">
								<div class="flex items-start justify-between gap-3">
									<div class="min-w-0">
										<p class="font-semibold">{{ check.label }}</p>
										<p class="mt-1 text-sm leading-6 text-muted-fg">{{ check.detail }}</p>
									</div>
									<span
										class="rounded-full px-2.5 py-1 text-xs font-semibold"
										:class="check.passed ? 'bg-success/15 text-success' : 'bg-warning/15 text-warning'"
									>
										{{ check.passed ? 'Passed' : 'Open' }}
									</span>
								</div>
							</div>
						</div>
					</div>
				</section>

				<section class="rounded-3xl border border-border bg-background p-5 shadow-2xl shadow-black/10">
					<div class="flex items-start gap-3">
						<div class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
							<svg viewBox="0 0 24 24" class="size-5" fill="none" aria-hidden="true">
								<path d="M5 12h14M12 5v14M7 7l10 10M17 7 7 17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
							</svg>
						</div>
						<div>
							<h4 class="font-semibold">Builder notes</h4>
							<p class="mt-2 text-sm leading-6 text-muted-fg">
								Use this as a customer flow, staff kiosk, or embedded order-detail module. The state shape maps cleanly to grocery pickup, rental returns, pharmacy collection, and click-and-collect retail.
							</p>
						</div>
					</div>
				</section>
			</aside>
		</div>
	</div>
</template>

Integration

How to use this block

Use this block when customers or field staff need to complete a pickup without opening a full admin workspace. The layout keeps arrival method, pickup code, item readiness, substitutions, ID checks, and handoff completion inside a compact mobile flow with a sticky action sheet.

  • Load pickup state from your order API with customer-facing ETA, store location, parking bay, verification rules, item readiness, and exception requirements.
  • Verify pickup codes server-side before completing a handoff. Treat the code field as intent capture, not proof by itself.
  • Model each item with readiness, substitution, temperature, restricted-item, and staff-note fields so fulfilment teams can explain delays clearly.
  • Persist arrival method and bay updates quickly through a lightweight endpoint or realtime channel so staff can stage the order.
  • Write handoff events to the order timeline with actor, timestamp, verification method, exception state, and proof details for support and fraud review.

Data

Recommended pickup payload

{
	order: {
		id: 'ord_7831',
		number: 'PU-7831',
		customerName: 'Maya Chen',
		status: 'arrived',
		pickupWindow: { startsAt: '2026-06-11T16:30:00Z', endsAt: '2026-06-11T17:00:00Z' }
	},
	location: {
		name: 'Soho Market',
		address: '18 Broadwick Street',
		bay: 'B2',
		arrivalMethod: 'Curbside'
	},
	verification: {
		pickupCode: '4821',
		requiresIdCheck: true,
		verifiedAt: null
	},
	items: [
		{ id: 'cold-bag', name: 'Chilled meal kit', quantity: 1, ready: true, temperature: 'Cold chain' },
		{ id: 'oat-milk', name: 'Oat milk multipack', quantity: 2, ready: false, substitution: 'Barista blend' }
	],
	handoff: {
		contactless: true,
		substitutionsAccepted: true,
		completedAt: null,
		events: []
	}
}

Customization

Implementation notes

Verification rules

Keep the UI optimistic, but complete handoff only after the backend validates pickup code, order state, restricted-item checks, and pickup window rules.

Exception handling

Represent substitutions, missing items, ID checks, and late arrivals as explicit blockers with clear customer copy and staff-owned resolution events.

Future updates

Useful follow-ups include QR scanning, geofence arrival, staff assignment, proof photo capture, partial handoff, and realtime kitchen or warehouse status.