Collect ready items from pickup boxes instead of "I picked this up"

A ready Order Item now gets the lowest-numbered free Pickup Box (or waits
for one to free up), shown as "Pickup box N" on its status. The customer
opens the box from their order and closes it again; closing marks the
item picked up and hands the box to the next waiting item.

On the order card the box button comes first, full width on mobile, with
a padlock that lifts on open and snaps shut before the button collapses
away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 11:31:23 +02:00
co-authored by Claude Opus 5.5
parent ac2aa770fb
commit edd34a00a7
11 changed files with 1316 additions and 58 deletions
@@ -299,6 +299,26 @@
}
}
.pickupButton {
overflow: hidden;
white-space: nowrap;
border-color: $color-text;
background: $color-text;
color: $color-background;
font-weight: 600;
&:hover:not(:disabled) {
border-color: $color-text;
opacity: 0.9;
}
@media (max-width: $breakpoint-tablet) {
flex-basis: 100%;
justify-content: center;
margin-bottom: $space-2;
}
}
.actionIcon {
width: 1rem;
height: 1rem;
+84 -21
View File
@@ -8,8 +8,9 @@ import { useBasket } from '#/lib/basket.tsx'
import {
advanceSimulatedKitchen,
cancelOrderItem,
closePickupBox,
getOrderStatus,
markOrderItemPickedUp,
openPickupBox,
} from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss'
@@ -31,6 +32,9 @@ const getItemBanner = (item: OrderItemStatusView) => {
animationKey: 'done',
variant: 'ready',
label: 'Ready for pickup',
detail: item.pickupBox
? `Pickup box ${item.pickupBox.number}`
: 'Waiting for a free pickup box',
} as const
}
if (item.status === 'picked_up') {
@@ -82,11 +86,6 @@ const BackArrowIcon = () => (
const actionIconPaths = {
buyAgain: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5'],
pickedUp: [
'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z',
'M3 6h18',
'M16 10a4 4 0 0 1-8 0',
],
help: [
'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3',
@@ -112,6 +111,38 @@ const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
</svg>
)
const LOCK_DURATION_SECONDS = 0.25
// The shackle lifts while the box is open, and drops back first when the
// button leaves after closing, so the lock visibly snaps shut.
const LockIcon = ({ open }: { open: boolean }) => {
const reduceMotion = useReducedMotion()
const transition = reduceMotion
? { duration: 0 }
: { duration: LOCK_DURATION_SECONDS, ease: 'easeOut' as const }
return (
<svg
className={styles.actionIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" />
<motion.path
d="M7 11V7a5 5 0 0 1 10 0v4"
initial={false}
animate={{ y: open ? -3 : 0 }}
exit={{ y: 0 }}
transition={transition}
/>
</svg>
)
}
const StepConnector = ({ filled }: { filled: boolean }) => {
const reduceMotion = useReducedMotion()
return (
@@ -194,6 +225,34 @@ export const OrderStatusView = ({
}
}
// Collapses to nothing on both axes, so the row closes the gap whether the
// button sits inline (desktop) or on its own full-width line (mobile); the
// negative margins cancel the flex gap it leaves behind.
const pickupButtonExit = reduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
width: 0,
height: 0,
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
borderWidth: 0,
marginRight: -8,
marginBottom: -8,
transition: {
delay: LOCK_DURATION_SECONDS + 0.1,
duration: 0.3,
ease: 'easeInOut' as const,
},
}
const togglePickupBox = (item: OrderItemStatusView) => {
const toggle = item.pickupBox?.isOpen ? closePickupBox : openPickupBox
return toggle({ data: { orderItemId: item.id, guestAccessToken } })
}
const runKitchenSimulation = async () => {
setSimulating(true)
setError(null)
@@ -349,6 +408,25 @@ export const OrderStatusView = ({
<EtaMessage etaSeconds={item.etaSeconds} />
)}
<div className={styles.actions}>
<AnimatePresence initial={false}>
{item.pickupBox && (
<motion.button
key="pickupBox"
type="button"
className={styles.pickupButton}
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () => togglePickupBox(item))
}
exit={pickupButtonExit}
>
<LockIcon open={item.pickupBox.isOpen} />
{item.pickupBox.isOpen
? `Close box ${item.pickupBox.number}`
: `Open box ${item.pickupBox.number}`}
</motion.button>
)}
</AnimatePresence>
<button
type="button"
disabled={pendingItemId === item.id}
@@ -358,21 +436,6 @@ export const OrderStatusView = ({
Buy again
</button>
{item.status === 'ready' && (
<button
type="button"
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
markOrderItemPickedUp({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
<ActionIcon name="pickedUp" />I picked this up
</button>
)}
<Link
to="/help"
search={{
+11
View File
@@ -1,4 +1,5 @@
import {
boolean,
integer,
pgTable,
serial,
@@ -108,6 +109,14 @@ export const menuItemAllergens = pgTable(
],
)
// A locker the customer collects a ready Order Item from, identified to them
// by the number on its door.
export const pickupBoxes = pgTable('pickup_boxes', {
id: serial().primaryKey(),
number: integer().notNull().unique(),
isOpen: boolean('is_open').notNull().default(false),
})
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
export type OrderStatus = (typeof orderStatusValues)[number]
@@ -151,6 +160,8 @@ export const orderItems = pgTable('order_items', {
status: text().$type<OrderItemStatus>().notNull().default('placed'),
// Only set once status is 'cancelled'.
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
// Set once the item is ready and a Pickup Box is free; kept after pickup.
pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
+6
View File
@@ -45,6 +45,11 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'Assembly Station 1', machineTagId: assemblyTag.id })
.returning()
const pickupBoxes = await db
.insert(schema.pickupBoxes)
.values([1, 2, 3, 4].map((number) => ({ number })))
.returning()
const [pizza] = await db
.insert(schema.menuItems)
.values({
@@ -177,6 +182,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
assembly: assemblyTag,
},
machines: { prep1, oven1, grill1, assembly1 },
pickupBoxes,
menuItems: { pizza, burger },
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
allergens,
+146 -19
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import { and, asc, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
@@ -32,6 +32,20 @@ export class OrderItemNotReadyError extends Error {
}
}
export class NoPickupBoxYetError extends Error {
constructor() {
super('No pickup box is free for this item yet')
this.name = 'NoPickupBoxYetError'
}
}
export class PickupBoxNotOpenError extends Error {
constructor() {
super('Open the pickup box before closing it')
this.name = 'PickupBoxNotOpenError'
}
}
export class NoStepInProgressError extends Error {
constructor() {
super('This item has no Preparation Step currently in progress')
@@ -617,6 +631,7 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
.update(schema.orderItems)
.set({ status: 'ready' })
.where(eq(schema.orderItems.id, orderItemId))
await assignFreePickupBoxes(tx)
}
// Freeing this machine may unblock another queued Step for the same Tag.
@@ -750,37 +765,126 @@ export const runKitchenSimulationTick = async (
}
}
export const markOrderItemPickedUp = async (
// Hands each free Pickup Box to a ready Order Item still waiting for one,
// oldest item first.
const assignFreePickupBoxes = async (db: DbOrTx) => {
const occupiedBoxIds = new Set(
(
await db
.select({ pickupBoxId: schema.orderItems.pickupBoxId })
.from(schema.orderItems)
.where(
and(
eq(schema.orderItems.status, 'ready'),
isNotNull(schema.orderItems.pickupBoxId),
),
)
).map((row) => row.pickupBoxId),
)
const freeBoxes = (
await db
.select()
.from(schema.pickupBoxes)
.orderBy(asc(schema.pickupBoxes.number))
).filter((box) => !occupiedBoxIds.has(box.id))
if (freeBoxes.length === 0) return
const waitingItems = await db
.select()
.from(schema.orderItems)
.where(
and(
eq(schema.orderItems.status, 'ready'),
isNull(schema.orderItems.pickupBoxId),
),
)
.orderBy(asc(schema.orderItems.id))
.limit(freeBoxes.length)
for (const [index, item] of waitingItems.entries()) {
await db
.update(schema.orderItems)
.set({ pickupBoxId: freeBoxes[index].id })
.where(eq(schema.orderItems.id, item.id))
}
}
const getPickupBoxForReadyItem = async (
tx: Tx,
orderItemId: number,
access: OrderAccess,
) => {
const orderItem = firstRow(
await tx
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
const order = firstRow(
await tx
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
assertOrderAccess(order, access)
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
const box = firstRow(
await tx
.select()
.from(schema.pickupBoxes)
.where(eq(schema.pickupBoxes.id, orderItem.pickupBoxId)),
)
if (!box) throw new Error('Pickup box not found')
return { orderItem, box }
}
// Stand-in for unlocking the physical box door until hardware integration
// lands, like the kitchen simulation does for Machines.
export const openPickupBox = async (
db: Db,
orderItemId: number,
access: OrderAccess,
) => {
await db.transaction(async (tx) => {
const orderItem = firstRow(
await tx
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
await tx
.update(schema.pickupBoxes)
.set({ isOpen: true })
.where(eq(schema.pickupBoxes.id, box.id))
})
}
// Closing the box after taking the item out is what counts as picking it up.
export const closePickupBox = async (
db: Db,
orderItemId: number,
access: OrderAccess,
) => {
await db.transaction(async (tx) => {
const { orderItem, box } = await getPickupBoxForReadyItem(
tx,
orderItemId,
access,
)
if (!orderItem) throw new Error('Order item not found')
const order = firstRow(
await tx
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
assertOrderAccess(order, access)
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
if (!box.isOpen) throw new PickupBoxNotOpenError()
await tx
.update(schema.pickupBoxes)
.set({ isOpen: false })
.where(eq(schema.pickupBoxes.id, box.id))
await tx
.update(schema.orderItems)
.set({ status: 'picked_up' })
.where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
await assignFreePickupBoxes(tx)
})
}
@@ -804,6 +908,8 @@ export type OrderItemStatusView = {
// Menu Item recreates its Options under new ids, so the ids at order time
// can't be reused. Options since removed from the menu are left out.
buyAgainOptionIds: Array<number>
// Only set while the item is ready and waiting in a box.
pickupBox: { number: number; isOpen: boolean } | null
etaSeconds: number | null
}
@@ -843,6 +949,20 @@ export const getOrderStatus = async (
.from(schema.orderItemOptions)
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
const pickupBoxIds = items
.filter((item) => item.status === 'ready')
.map((item) => item.pickupBoxId)
.filter((id) => id !== null)
const pickupBoxesById = new Map(
(pickupBoxIds.length === 0
? []
: await db
.select()
.from(schema.pickupBoxes)
.where(inArray(schema.pickupBoxes.id, pickupBoxIds))
).map((box) => [box.id, box]),
)
const currentOptionRows = await db
.select()
.from(schema.menuItemOptions)
@@ -1021,6 +1141,10 @@ export const getOrderStatus = async (
const options = optionRows.filter(
(option) => option.orderItemId === item.id,
)
const pickupBox =
item.status === 'ready' && item.pickupBoxId !== null
? pickupBoxesById.get(item.pickupBoxId)
: undefined
return {
id: item.id,
@@ -1045,6 +1169,9 @@ export const getOrderStatus = async (
options.some((option) => option.name === current.name),
)
.map((current) => current.id),
pickupBox: pickupBox
? { number: pickupBox.number, isOpen: pickupBox.isOpen }
: null,
etaSeconds,
}
})
+8 -6
View File
@@ -109,14 +109,16 @@ export const cancelOrderItem = createServerFn({ method: 'POST' })
),
)
export const markOrderItemPickedUp = createServerFn({ method: 'POST' })
export const openPickupBox = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() }))
.handler(async ({ data }) =>
ordering.markOrderItemPickedUp(
db,
data.orderItemId,
await resolveAccess(data),
),
ordering.openPickupBox(db, data.orderItemId, await resolveAccess(data)),
)
export const closePickupBox = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() }))
.handler(async ({ data }) =>
ordering.closePickupBox(db, data.orderItemId, await resolveAccess(data)),
)
// Internal stand-in for a Machine finishing a Preparation Step, until