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:
@@ -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;
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user