Keep the closed box visible and tidy the order item card

- After closing, the box button stays disabled as "Box N closed" for
  5 minutes, timed from a new picked_up_at so it survives a reload
- Picked-up items keep showing their pickup box number
- Hide the steps toggle on ready and picked-up items
- Move "Buy again" and "Get help" into a dots menu in the item header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:11:47 +02:00
co-authored by Claude Opus 5.5
parent d68806aaf8
commit 440f9e5696
8 changed files with 1124 additions and 42 deletions
@@ -94,10 +94,81 @@
.itemHeader {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
margin-bottom: $space-2;
}
.menuButton {
display: inline-flex;
flex-shrink: 0;
margin: (-$space-2) (-$space-2) (-$space-2) $space-2;
padding: $space-2;
border: none;
border-radius: $radius;
background: none;
color: $color-text-muted;
cursor: pointer;
svg {
width: 1.25rem;
height: 1.25rem;
stroke-width: 3;
}
&:hover {
color: $color-text;
}
}
.menu {
position: fixed;
position-area: bottom span-left;
position-try-fallbacks: flip-block;
inset: auto;
margin: $space-1 0;
padding: $space-1;
min-width: 160px;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
color: $color-text;
font-weight: 400;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.menuItem {
display: flex;
align-items: center;
gap: $space-2;
width: 100%;
padding: $space-2 $space-3;
border: none;
border-radius: calc(#{$radius} - 2px);
background: none;
color: inherit;
font: inherit;
font-size: 0.9rem;
text-align: left;
text-decoration: none;
cursor: pointer;
&:hover:not(:disabled) {
background: $color-background;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
}
.itemOptions {
margin: 0 0 $space-2;
font-size: 0.85rem;
+82 -36
View File
@@ -1,6 +1,6 @@
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { HoldButton } from '#/components/ui/HoldButton.tsx'
import type { OrderStatus } from '#/db/schema.ts'
@@ -43,6 +43,9 @@ const getItemBanner = (item: OrderItemStatusView) => {
animationKey: 'done',
variant: 'pickedUp',
label: 'Picked up',
detail: item.pickupBox
? `Pickup box ${item.pickupBox.number}`
: undefined,
} as const
}
if (item.status === 'cancelled') {
@@ -93,6 +96,7 @@ const actionIconPaths = {
'M12 17h.01',
],
cancel: ['M18 6 6 18', 'M6 6l12 12'],
more: ['M12 12h.01', 'M19 12h.01', 'M5 12h.01'],
}
const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
@@ -113,13 +117,12 @@ const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
)
const LOCK_DURATION_SECONDS = 0.25
const CLOSED_BOX_VISIBLE_MS = 5 * 60 * 1000
// Same commands in both so the shackle morphs: unlocked, the left leg
// stretches up from the body and the right leg shrinks to a stub.
const LOCKED_SHACKLE = 'M7 11V7a5 5 0 0 1 10 0v4'
const UNLOCKED_SHACKLE = 'M7 11V4a5 5 0 0 1 10 0v1'
// 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
@@ -140,7 +143,6 @@ const LockIcon = ({ open }: { open: boolean }) => {
<motion.path
initial={false}
animate={{ d: open ? UNLOCKED_SHACKLE : LOCKED_SHACKLE }}
exit={{ d: LOCKED_SHACKLE }}
transition={transition}
/>
</svg>
@@ -192,6 +194,24 @@ export const OrderStatusView = ({
const [initialBannerKeys] = useState(
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
)
// A closed box's button stays on screen, disabled and locked, for a while
// after pickup; re-render when the next one is due to animate out.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const hideAt = status.items
.flatMap((item) =>
item.pickedUpAt
? [item.pickedUpAt.getTime() + CLOSED_BOX_VISIBLE_MS]
: [],
)
.filter((time) => time > now)
if (hideAt.length === 0) return
const timeout = setTimeout(
() => setNow(Date.now()),
Math.min(...hideAt) - Date.now(),
)
return () => clearTimeout(timeout)
}, [status.items, now])
const [expandedItemIds, setExpandedItemIds] = useState<ReadonlySet<number>>(
new Set(),
)
@@ -246,11 +266,7 @@ export const OrderStatusView = ({
marginTop: 0,
marginRight: -8,
marginBottom: -8,
transition: {
delay: LOCK_DURATION_SECONDS + 0.1,
duration: 0.3,
ease: 'easeInOut' as const,
},
transition: { duration: 0.3, ease: 'easeInOut' as const },
}
const togglePickupBox = (item: OrderItemStatusView) => {
@@ -328,10 +344,58 @@ export const OrderStatusView = ({
<ul className={styles.items}>
{status.items.map((item) => {
const banner = getItemBanner(item)
const boxClosed = item.status !== 'ready'
const pickupBox =
item.status === 'ready' ||
(item.pickedUpAt !== null &&
now - item.pickedUpAt.getTime() < CLOSED_BOX_VISIBLE_MS)
? item.pickupBox
: null
return (
<li key={item.id} className={styles.item}>
<div className={styles.itemHeader}>
<span className={styles.itemName}>{item.menuItemName}</span>
<button
type="button"
className={styles.menuButton}
aria-label={`More actions for ${item.menuItemName}`}
aria-haspopup="menu"
popoverTarget={`item-menu-${item.id}`}
style={{ anchorName: `--item-menu-${item.id}` }}
>
<ActionIcon name="more" />
</button>
<div
id={`item-menu-${item.id}`}
popover="auto"
role="menu"
className={styles.menu}
style={{ positionAnchor: `--item-menu-${item.id}` }}
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
disabled={pendingItemId === item.id}
onClick={() => buyAgain(item)}
>
<ActionIcon name="buyAgain" />
Buy again
</button>
<Link
role="menuitem"
className={styles.menuItem}
to="/help"
search={{
orderId: status.id,
item: item.menuItemName,
status: item.status,
}}
>
<ActionIcon name="help" />
Get help
</Link>
</div>
</div>
{item.options.length > 0 && (
<p className={styles.itemOptions}>
@@ -396,7 +460,7 @@ export const OrderStatusView = ({
)
}
/>
{isSettled(item) && (
{item.status === 'cancelled' && (
<button
type="button"
className={styles.stepsToggle}
@@ -414,43 +478,25 @@ export const OrderStatusView = ({
)}
<div className={styles.actions}>
<AnimatePresence initial={false}>
{item.pickupBox && (
{pickupBox && (
<HoldButton
key="pickupBox"
className={styles.pickupButton}
disabled={pendingItemId === item.id}
disabled={pendingItemId === item.id || boxClosed}
onHold={() =>
void runAction(item.id, () => togglePickupBox(item))
}
exit={pickupButtonExit}
>
<LockIcon open={item.pickupBox.isOpen} />
{item.pickupBox.isOpen
? `Hold to close box ${item.pickupBox.number}`
: `Hold to open box ${item.pickupBox.number}`}
<LockIcon open={pickupBox.isOpen} />
{boxClosed
? `Box ${pickupBox.number} closed`
: pickupBox.isOpen
? `Hold to close box ${pickupBox.number}`
: `Hold to open box ${pickupBox.number}`}
</HoldButton>
)}
</AnimatePresence>
<button
type="button"
disabled={pendingItemId === item.id}
onClick={() => buyAgain(item)}
>
<ActionIcon name="buyAgain" />
Buy again
</button>
<Link
to="/help"
search={{
orderId: status.id,
item: item.menuItemName,
status: item.status,
}}
>
<ActionIcon name="help" />
Get help
</Link>
{item.cancellable && (
<button
type="button"
+1
View File
@@ -162,6 +162,7 @@ export const orderItems = pgTable('order_items', {
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),
pickedUpAt: timestamp('picked_up_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
+12 -5
View File
@@ -880,7 +880,7 @@ export const closePickupBox = async (
.where(eq(schema.pickupBoxes.id, box.id))
await tx
.update(schema.orderItems)
.set({ status: 'picked_up' })
.set({ status: 'picked_up', pickedUpAt: new Date() })
.where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
@@ -908,8 +908,10 @@ 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.
// The box a ready item waits in, still reported once it's picked up (with
// isOpen false, since the box may since have gone to another item).
pickupBox: { number: number; isOpen: boolean } | null
pickedUpAt: Date | null
etaSeconds: number | null
}
@@ -950,7 +952,7 @@ export const getOrderStatus = async (
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
const pickupBoxIds = items
.filter((item) => item.status === 'ready')
.filter((item) => item.status === 'ready' || item.status === 'picked_up')
.map((item) => item.pickupBoxId)
.filter((id) => id !== null)
const pickupBoxesById = new Map(
@@ -1142,7 +1144,8 @@ export const getOrderStatus = async (
(option) => option.orderItemId === item.id,
)
const pickupBox =
item.status === 'ready' && item.pickupBoxId !== null
(item.status === 'ready' || item.status === 'picked_up') &&
item.pickupBoxId !== null
? pickupBoxesById.get(item.pickupBoxId)
: undefined
@@ -1170,8 +1173,12 @@ export const getOrderStatus = async (
)
.map((current) => current.id),
pickupBox: pickupBox
? { number: pickupBox.number, isOpen: pickupBox.isOpen }
? {
number: pickupBox.number,
isOpen: item.status === 'ready' && pickupBox.isOpen,
}
: null,
pickedUpAt: item.pickedUpAt,
etaSeconds,
}
})