Discard items left in their pickup box past a 15-minute window
An item's pickup window starts when it goes into its box. Once it runs out, the item is discarded without a refund and the box is freed; a box that's open at that moment is left alone. With no scheduler yet, reads, box actions and the kitchen simulation check for expired items first. The order card shows a live countdown under "Ready for pickup" and a "Not picked up in time" banner once discarded. Also bounces the lock icon and animates the pickup button's label changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -373,6 +373,7 @@
|
||||
.pickupButton {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.3s ease;
|
||||
border-color: $color-text;
|
||||
background: $color-text;
|
||||
color: $color-background;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { HoldButton } from '#/components/ui/HoldButton.tsx'
|
||||
import type { OrderStatus } from '#/db/schema.ts'
|
||||
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
||||
import { useBasket } from '#/lib/basket.tsx'
|
||||
import { formatCountdown } from '#/lib/format.ts'
|
||||
import {
|
||||
advanceSimulatedKitchen,
|
||||
cancelOrderItem,
|
||||
@@ -23,19 +24,32 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const isSettled = (item: OrderItemStatusView) =>
|
||||
item.status === 'ready' ||
|
||||
item.status === 'picked_up' ||
|
||||
item.status === 'cancelled'
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'discarded'
|
||||
|
||||
// `animationKey` groups variants that should read as one continuous banner:
|
||||
// going from ready to picked up swaps the label without replaying the check.
|
||||
const getItemBanner = (item: OrderItemStatusView) => {
|
||||
// `now` is null until the page hydrates, so the server and first client
|
||||
// render agree and the countdown only appears once it can tick.
|
||||
const getItemBanner = (item: OrderItemStatusView, now: number | null) => {
|
||||
if (item.status === 'ready') {
|
||||
return {
|
||||
animationKey: 'done',
|
||||
variant: 'ready',
|
||||
label: 'Ready for pickup',
|
||||
detail: item.pickupBox
|
||||
? `Pickup box ${item.pickupBox.number}`
|
||||
: 'Waiting for a free pickup box',
|
||||
detail: !item.pickupBox
|
||||
? 'Waiting for a free pickup box'
|
||||
: item.pickupDeadline && now !== null
|
||||
? `Pickup box ${item.pickupBox.number} · ${formatCountdown(item.pickupDeadline.getTime() - now)} left`
|
||||
: `Pickup box ${item.pickupBox.number}`,
|
||||
} as const
|
||||
}
|
||||
if (item.status === 'discarded') {
|
||||
return {
|
||||
animationKey: 'discarded',
|
||||
variant: 'cancelled',
|
||||
label: 'Not picked up in time',
|
||||
detail: `${item.pickupBox ? `Pickup box ${item.pickupBox.number}` : 'The pickup box'} was emptied after 15 minutes. This item can't be refunded.`,
|
||||
} as const
|
||||
}
|
||||
if (item.status === 'picked_up') {
|
||||
@@ -68,7 +82,7 @@ const getItemBanner = (item: OrderItemStatusView) => {
|
||||
}
|
||||
|
||||
const bannerKey = (item: OrderItemStatusView) => {
|
||||
const banner = getItemBanner(item)
|
||||
const banner = getItemBanner(item, null)
|
||||
return banner ? `${item.id}:${banner.animationKey}` : null
|
||||
}
|
||||
|
||||
@@ -116,20 +130,28 @@ const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
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'
|
||||
// Overshoots past the target, so the shackle springs open and snaps shut.
|
||||
const SHACKLE_EASE = [0.34, 1.8, 0.64, 1] as const
|
||||
|
||||
const pickupButtonLabel = (
|
||||
box: { number: number; isOpen: boolean },
|
||||
closed: boolean,
|
||||
) => {
|
||||
if (closed) return `Box ${box.number} closed`
|
||||
return box.isOpen
|
||||
? `Hold to close box ${box.number}`
|
||||
: `Hold to open box ${box.number}`
|
||||
}
|
||||
|
||||
const LockIcon = ({ open }: { open: boolean }) => {
|
||||
const reduceMotion = useReducedMotion()
|
||||
const transition = reduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: LOCK_DURATION_SECONDS, ease: 'easeOut' as const }
|
||||
return (
|
||||
<svg
|
||||
<motion.svg
|
||||
className={`${styles.actionIcon} ${styles.lockIcon}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -138,14 +160,34 @@ const LockIcon = ({ open }: { open: boolean }) => {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
style={{ originY: 1 }}
|
||||
initial={false}
|
||||
animate={
|
||||
open
|
||||
? {
|
||||
y: [0, -4, 1, -1.5, 0],
|
||||
scaleX: [1, 0.94, 1.06, 0.98, 1],
|
||||
scaleY: [1, 1.1, 0.92, 1.03, 1],
|
||||
}
|
||||
: {
|
||||
y: [0, 2, -3.5, 1, -1, 0],
|
||||
scaleX: [1, 1.14, 0.94, 1.04, 0.99, 1],
|
||||
scaleY: [1, 0.8, 1.1, 0.96, 1.02, 1],
|
||||
}
|
||||
}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : { duration: 0.65, ease: 'easeOut' }
|
||||
}
|
||||
>
|
||||
<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
|
||||
initial={false}
|
||||
animate={{ d: open ? UNLOCKED_SHACKLE : LOCKED_SHACKLE }}
|
||||
transition={transition}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : { duration: 0.4, ease: SHACKLE_EASE }
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -194,10 +236,22 @@ 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.
|
||||
// Ticks every second while a pickup countdown runs; otherwise only wakes
|
||||
// when a closed box's button (kept for a while after pickup) is due to go.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
useEffect(() => setHydrated(true), [])
|
||||
const hasPickupCountdown = status.items.some(
|
||||
(item) => item.pickupDeadline !== null,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!hasPickupCountdown) return
|
||||
setNow(Date.now())
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [hasPickupCountdown])
|
||||
useEffect(() => {
|
||||
if (hasPickupCountdown) return
|
||||
const hideAt = status.items
|
||||
.flatMap((item) =>
|
||||
item.pickedUpAt
|
||||
@@ -211,7 +265,22 @@ export const OrderStatusView = ({
|
||||
Math.min(...hideAt) - Date.now(),
|
||||
)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [status.items, now])
|
||||
}, [status.items, now, hasPickupCountdown])
|
||||
// Once a countdown runs out, reload so the server can empty the box and the
|
||||
// item shows as discarded. Only once per item: an open box isn't emptied.
|
||||
const refreshedExpiredItemIds = useRef(new Set<number>())
|
||||
useEffect(() => {
|
||||
const newlyExpired = status.items.filter(
|
||||
(item) =>
|
||||
item.pickupDeadline !== null &&
|
||||
item.pickupDeadline.getTime() <= now &&
|
||||
!refreshedExpiredItemIds.current.has(item.id),
|
||||
)
|
||||
if (newlyExpired.length === 0) return
|
||||
for (const item of newlyExpired)
|
||||
refreshedExpiredItemIds.current.add(item.id)
|
||||
void router.invalidate()
|
||||
}, [status.items, now, router])
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<ReadonlySet<number>>(
|
||||
new Set(),
|
||||
)
|
||||
@@ -343,7 +412,7 @@ export const OrderStatusView = ({
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<ul className={styles.items}>
|
||||
{status.items.map((item) => {
|
||||
const banner = getItemBanner(item)
|
||||
const banner = getItemBanner(item, hydrated ? now : null)
|
||||
const boxClosed = item.status !== 'ready'
|
||||
const pickupBox =
|
||||
item.status === 'ready' ||
|
||||
@@ -489,11 +558,17 @@ export const OrderStatusView = ({
|
||||
exit={pickupButtonExit}
|
||||
>
|
||||
<LockIcon open={pickupBox.isOpen} />
|
||||
{boxClosed
|
||||
? `Box ${pickupBox.number} closed`
|
||||
: pickupBox.isOpen
|
||||
? `Hold to close box ${pickupBox.number}`
|
||||
: `Hold to open box ${pickupBox.number}`}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={pickupButtonLabel(pickupBox, boxClosed)}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: reduceMotion ? 0 : 0.18 }}
|
||||
>
|
||||
{pickupButtonLabel(pickupBox, boxClosed)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</HoldButton>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -138,7 +138,8 @@
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
&[data-progress='cancelled'] {
|
||||
&[data-progress='cancelled'],
|
||||
&[data-progress='discarded'] {
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ export const orderItemStatusValues = [
|
||||
'ready',
|
||||
'picked_up',
|
||||
'cancelled',
|
||||
// Left uncollected in its Pickup Box past the pickup window; not refunded.
|
||||
'discarded',
|
||||
] as const
|
||||
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
|
||||
|
||||
@@ -162,6 +164,8 @@ 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),
|
||||
// When the item went into its box, which starts the pickup window.
|
||||
placedInBoxAt: timestamp('placed_in_box_at'),
|
||||
pickedUpAt: timestamp('picked_up_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
+92
-11
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
|
||||
import { and, asc, desc, eq, inArray, isNotNull, isNull, lt } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
|
||||
import * as schema from '#/db/schema.ts'
|
||||
@@ -39,6 +39,13 @@ export class NoPickupBoxYetError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class PickupWindowExpiredError extends Error {
|
||||
constructor() {
|
||||
super('This item was not picked up in time and has been discarded')
|
||||
this.name = 'PickupWindowExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export class PickupBoxNotOpenError extends Error {
|
||||
constructor() {
|
||||
super('Open the pickup box before closing it')
|
||||
@@ -491,7 +498,10 @@ const updateOrderCompleteIfEverySiblingIsDone = async (
|
||||
.where(eq(schema.orderItems.orderId, orderId))
|
||||
|
||||
const orderComplete = siblingItems.every(
|
||||
(item) => item.status === 'picked_up' || item.status === 'cancelled',
|
||||
(item) =>
|
||||
item.status === 'picked_up' ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'discarded',
|
||||
)
|
||||
|
||||
if (orderComplete) {
|
||||
@@ -528,7 +538,11 @@ export const cancelOrderItem = async (
|
||||
if (orderItem.status === 'cancelled') {
|
||||
throw new OrderItemNotCancellableError('it has already been cancelled')
|
||||
}
|
||||
if (orderItem.status === 'ready' || orderItem.status === 'picked_up') {
|
||||
if (
|
||||
orderItem.status === 'ready' ||
|
||||
orderItem.status === 'picked_up' ||
|
||||
orderItem.status === 'discarded'
|
||||
) {
|
||||
throw new OrderItemNotCancellableError('it is already done cooking')
|
||||
}
|
||||
|
||||
@@ -742,6 +756,8 @@ export const runKitchenSimulationTick = async (
|
||||
policy: KitchenSimulationPolicy,
|
||||
now = new Date(),
|
||||
) => {
|
||||
await discardUncollectedItems(db, now)
|
||||
|
||||
const inProgressSteps = await db
|
||||
.select()
|
||||
.from(schema.preparationStepInstances)
|
||||
@@ -804,11 +820,55 @@ const assignFreePickupBoxes = async (db: DbOrTx) => {
|
||||
for (const [index, item] of waitingItems.entries()) {
|
||||
await db
|
||||
.update(schema.orderItems)
|
||||
.set({ pickupBoxId: freeBoxes[index].id })
|
||||
.set({ pickupBoxId: freeBoxes[index].id, placedInBoxAt: new Date() })
|
||||
.where(eq(schema.orderItems.id, item.id))
|
||||
}
|
||||
}
|
||||
|
||||
export const PICKUP_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
// Empties every box whose item has sat uncollected past the pickup window,
|
||||
// without a refund, and hands the freed boxes on. An open box is skipped: its
|
||||
// customer is taking the item out right now. Nothing runs this on a schedule
|
||||
// yet, so reads and box actions call it first to bring state up to date.
|
||||
export const discardUncollectedItems = async (db: DbOrTx, now = new Date()) => {
|
||||
const expiredItems = await db
|
||||
.select({
|
||||
id: schema.orderItems.id,
|
||||
orderId: schema.orderItems.orderId,
|
||||
})
|
||||
.from(schema.orderItems)
|
||||
.innerJoin(
|
||||
schema.pickupBoxes,
|
||||
eq(schema.orderItems.pickupBoxId, schema.pickupBoxes.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.orderItems.status, 'ready'),
|
||||
eq(schema.pickupBoxes.isOpen, false),
|
||||
lt(
|
||||
schema.orderItems.placedInBoxAt,
|
||||
new Date(now.getTime() - PICKUP_WINDOW_MS),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (expiredItems.length === 0) return
|
||||
|
||||
await db
|
||||
.update(schema.orderItems)
|
||||
.set({ status: 'discarded' })
|
||||
.where(
|
||||
inArray(
|
||||
schema.orderItems.id,
|
||||
expiredItems.map((item) => item.id),
|
||||
),
|
||||
)
|
||||
for (const orderId of new Set(expiredItems.map((item) => item.orderId))) {
|
||||
await updateOrderCompleteIfEverySiblingIsDone(db, orderId)
|
||||
}
|
||||
await assignFreePickupBoxes(db)
|
||||
}
|
||||
|
||||
const getPickupBoxForReadyItem = async (
|
||||
tx: Tx,
|
||||
orderItemId: number,
|
||||
@@ -831,6 +891,7 @@ const getPickupBoxForReadyItem = async (
|
||||
if (!order) throw new Error('Order not found')
|
||||
assertOrderAccess(order, access)
|
||||
|
||||
if (orderItem.status === 'discarded') throw new PickupWindowExpiredError()
|
||||
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
|
||||
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
|
||||
|
||||
@@ -852,6 +913,7 @@ export const openPickupBox = async (
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
await db.transaction(async (tx) => {
|
||||
await discardUncollectedItems(tx)
|
||||
const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
|
||||
await tx
|
||||
.update(schema.pickupBoxes)
|
||||
@@ -867,6 +929,7 @@ export const closePickupBox = async (
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
await db.transaction(async (tx) => {
|
||||
await discardUncollectedItems(tx)
|
||||
const { orderItem, box } = await getPickupBoxForReadyItem(
|
||||
tx,
|
||||
orderItemId,
|
||||
@@ -908,10 +971,13 @@ 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>
|
||||
// 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).
|
||||
// The box a ready item waits in, still reported once it's picked up or
|
||||
// discarded (with isOpen false, since the box may since have gone to
|
||||
// another item).
|
||||
pickupBox: { number: number; isOpen: boolean } | null
|
||||
pickedUpAt: Date | null
|
||||
// When the box will be emptied if the item is still there.
|
||||
pickupDeadline: Date | null
|
||||
etaSeconds: number | null
|
||||
}
|
||||
|
||||
@@ -920,6 +986,8 @@ export const getOrderStatus = async (
|
||||
orderId: number,
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
await discardUncollectedItems(db)
|
||||
|
||||
const order = firstRow(
|
||||
await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)),
|
||||
)
|
||||
@@ -952,7 +1020,6 @@ export const getOrderStatus = async (
|
||||
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
|
||||
|
||||
const pickupBoxIds = items
|
||||
.filter((item) => item.status === 'ready' || item.status === 'picked_up')
|
||||
.map((item) => item.pickupBoxId)
|
||||
.filter((id) => id !== null)
|
||||
const pickupBoxesById = new Map(
|
||||
@@ -1134,7 +1201,8 @@ export const getOrderStatus = async (
|
||||
if (
|
||||
item.status === 'ready' ||
|
||||
item.status === 'picked_up' ||
|
||||
item.status === 'cancelled'
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'discarded'
|
||||
) {
|
||||
etaSeconds = null
|
||||
}
|
||||
@@ -1144,7 +1212,6 @@ export const getOrderStatus = async (
|
||||
(option) => option.orderItemId === item.id,
|
||||
)
|
||||
const pickupBox =
|
||||
(item.status === 'ready' || item.status === 'picked_up') &&
|
||||
item.pickupBoxId !== null
|
||||
? pickupBoxesById.get(item.pickupBoxId)
|
||||
: undefined
|
||||
@@ -1158,6 +1225,7 @@ export const getOrderStatus = async (
|
||||
item.status !== 'cancelled' &&
|
||||
item.status !== 'ready' &&
|
||||
item.status !== 'picked_up' &&
|
||||
item.status !== 'discarded' &&
|
||||
currentInstance?.status === 'queued',
|
||||
steps,
|
||||
options: options.map(({ name, priceDeltaCents }) => ({
|
||||
@@ -1179,6 +1247,10 @@ export const getOrderStatus = async (
|
||||
}
|
||||
: null,
|
||||
pickedUpAt: item.pickedUpAt,
|
||||
pickupDeadline:
|
||||
item.status === 'ready' && item.placedInBoxAt
|
||||
? new Date(item.placedInBoxAt.getTime() + PICKUP_WINDOW_MS)
|
||||
: null,
|
||||
etaSeconds,
|
||||
}
|
||||
})
|
||||
@@ -1198,9 +1270,15 @@ export const getOrderStatus = async (
|
||||
export const getOrderProgress = (
|
||||
items: Array<{ status: schema.OrderItemStatus; started: boolean }>,
|
||||
) => {
|
||||
const activeItems = items.filter((item) => item.status !== 'cancelled')
|
||||
const activeItems = items.filter(
|
||||
(item) => item.status !== 'cancelled' && item.status !== 'discarded',
|
||||
)
|
||||
const activeStatuses = activeItems.map((item) => item.status)
|
||||
if (activeStatuses.length === 0) return 'cancelled'
|
||||
if (activeStatuses.length === 0) {
|
||||
return items.some((item) => item.status === 'discarded')
|
||||
? 'discarded'
|
||||
: 'cancelled'
|
||||
}
|
||||
if (activeStatuses.every((status) => status === 'picked_up'))
|
||||
return 'picked_up'
|
||||
if (
|
||||
@@ -1314,6 +1392,7 @@ export const getOrdersForCustomer = async (
|
||||
db: Db,
|
||||
customerId: string,
|
||||
): Promise<Array<CustomerOrderSummary>> => {
|
||||
await discardUncollectedItems(db)
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(schema.orders)
|
||||
@@ -1351,6 +1430,7 @@ export type AdminOrderSummary = CustomerOrderSummary & {
|
||||
export const getAllOrders = async (
|
||||
db: Db,
|
||||
): Promise<Array<AdminOrderSummary>> => {
|
||||
await discardUncollectedItems(db)
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(schema.orders)
|
||||
@@ -1384,6 +1464,7 @@ export const getGuestOrderSummaries = async (
|
||||
refs: Array<GuestOrderRef>,
|
||||
): Promise<Array<CustomerOrderSummary>> => {
|
||||
if (refs.length === 0) return []
|
||||
await discardUncollectedItems(db)
|
||||
|
||||
const tokenByOrderId = new Map(
|
||||
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
|
||||
|
||||
@@ -63,4 +63,11 @@ export const formatEtaParts = (etaSeconds: number, now = new Date()) =>
|
||||
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
|
||||
: { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
|
||||
|
||||
export const formatCountdown = (remainingMs: number) => {
|
||||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
|
||||
|
||||
@@ -16,6 +16,7 @@ const orderStatusHelp: Record<OrderItemStatus, string> = {
|
||||
ready: 'Your order is ready for pickup. If something’s off with it, let us know below.',
|
||||
picked_up: 'This order has already been picked up. If something was wrong with it, let us know below.',
|
||||
cancelled: 'This order was cancelled. If that wasn’t expected, let us know below.',
|
||||
discarded: 'This item wasn’t picked up within 15 minutes, so its pickup box was emptied and it can’t be refunded. If something went wrong, let us know below.',
|
||||
}
|
||||
|
||||
const faqs = [
|
||||
|
||||
Reference in New Issue
Block a user