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