Animate the ETA, hide delays, and tidy the order card actions

- Slide the "Ready in about" value when it changes (new EtaMessage)
- Drop the "Running late" banner; delays only move the ETA now
- Remove the cancel-info tooltip and its icon button
- Shorten the steps toggle to "Steps" / "Hide"
- Add icons and more padding to the order card action buttons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 11:02:35 +02:00
co-authored by Claude Opus 5.5
parent 7cf286850c
commit 1dae53f90a
11 changed files with 104 additions and 151 deletions
@@ -0,0 +1,19 @@
@use '../../styles/variables' as *;
.eta {
margin: 0 0 $space-2;
font-size: 0.9rem;
color: $color-success;
}
.value {
position: relative;
display: inline-flex;
overflow: hidden;
vertical-align: bottom;
}
.valueText {
display: inline-block;
white-space: nowrap;
}
+30
View File
@@ -0,0 +1,30 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { formatEtaParts } from '#/lib/format.ts'
import styles from './EtaMessage.module.scss'
export const EtaMessage = ({ etaSeconds }: { etaSeconds: number }) => {
const reduceMotion = useReducedMotion()
const { prefix, value } = formatEtaParts(etaSeconds)
return (
<p className={styles.eta}>
{prefix}{' '}
<span className={styles.value}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={value}
className={styles.valueText}
initial={reduceMotion ? false : { y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { y: '-100%', opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.35, ease: 'easeOut' }}
>
{value}
</motion.span>
</AnimatePresence>
</span>
</p>
)
}
@@ -9,10 +9,6 @@
&[data-variant='cancelled'] {
color: $color-danger;
}
&[data-variant='delayed'] {
color: $color-warning;
}
}
.icon {
+1 -3
View File
@@ -2,14 +2,12 @@ import { motion, useReducedMotion } from 'framer-motion'
import styles from './ItemStatusBanner.module.scss'
export type ItemStatusBannerVariant =
'ready' | 'pickedUp' | 'cancelled' | 'delayed'
export type ItemStatusBannerVariant = 'ready' | 'pickedUp' | 'cancelled'
const iconPaths = {
ready: ['M7.5 12.5l3 3 6-6.5'],
pickedUp: ['M7.5 12.5l3 3 6-6.5'],
cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'],
delayed: ['M12 7v5', 'M12 12l3.5 2'],
} satisfies Record<ItemStatusBannerVariant, Array<string>>
type ItemStatusBannerProps = {
@@ -1,13 +1,5 @@
@use '../../styles/variables' as *;
@position-try --cancel-hint-flip {
position-area: bottom;
--hint-arrow-top: auto;
--hint-arrow-bottom: 100%;
--hint-arrow-color-top: transparent;
--hint-arrow-color-bottom: #{$color-text};
}
.page {
max-width: 720px;
margin: 0 auto;
@@ -277,12 +269,6 @@
opacity: 0.6;
}
.eta {
margin: 0 0 $space-2;
font-size: 0.9rem;
color: $color-success;
}
.actions {
display: flex;
flex-wrap: wrap;
@@ -291,7 +277,10 @@
button,
a {
padding: $space-1 $space-3;
display: inline-flex;
align-items: center;
gap: $space-1;
padding: $space-2 $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
@@ -310,66 +299,12 @@
}
}
.infoButton {
display: inline-flex;
align-items: center;
flex-shrink: 0;
gap: $space-1;
padding: $space-1 $space-2;
color: $color-text-muted;
font-size: 0.85rem;
white-space: nowrap;
&:hover:not(:disabled) {
color: $color-primary;
border-color: $color-primary;
}
}
.infoIcon {
.actionIcon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.cancelHint {
position: fixed;
position-area: top;
position-try-fallbacks: --cancel-hint-flip;
inset: auto;
overflow: visible;
height: auto;
margin: $space-2;
padding: $space-2 $space-3;
width: max-content;
max-width: 260px;
border: none;
border-radius: $radius;
background: $color-text;
color: $color-background;
font-size: 0.8rem;
line-height: 1.4;
text-align: left;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
--hint-arrow-top: 100%;
--hint-arrow-bottom: auto;
--hint-arrow-color-top: #{$color-text};
--hint-arrow-color-bottom: transparent;
&::after {
content: '';
position: absolute;
left: 50%;
top: var(--hint-arrow-top);
bottom: var(--hint-arrow-bottom);
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--hint-arrow-color-top);
border-bottom-color: var(--hint-arrow-color-bottom);
}
}
.cancelButton {
padding: $space-1 $space-2;
border: none;
+27 -60
View File
@@ -4,7 +4,6 @@ import { useState } from 'react'
import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { formatDuration, formatEtaMessage } from '#/lib/format.ts'
import {
advanceSimulatedKitchen,
cancelOrderItem,
@@ -13,6 +12,7 @@ import {
} from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss'
import { EtaMessage } from './EtaMessage.tsx'
import { ItemStatusBanner } from './ItemStatusBanner.tsx'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -55,14 +55,6 @@ const getItemBanner = (item: OrderItemStatusView) => {
detail: "You've been refunded for this item.",
} as const)
}
if (item.delaySeconds > 0) {
return {
animationKey: 'delayed',
variant: 'delayed',
label: 'Running late',
detail: `About ${formatDuration(item.delaySeconds)} behind schedule`,
} as const
}
return null
}
@@ -71,20 +63,6 @@ const bannerKey = (item: OrderItemStatusView) => {
return banner ? `${item.id}:${banner.animationKey}` : null
}
const cancelBlockedReason = (item: OrderItemStatusView) => {
if (
item.cancellable ||
item.status === 'cancelled' ||
item.status === 'picked_up'
) {
return null
}
if (item.status === 'ready') {
return "This item is already done cooking, so it's too late to cancel. If something's wrong with it, use Get help below."
}
return "This item has already started cooking, so the kitchen can't stop or undo it. If something's wrong, use Get help below."
}
const BackArrowIcon = () => (
<svg
className={styles.backArrow}
@@ -101,9 +79,24 @@ const BackArrowIcon = () => (
</svg>
)
const InfoIcon = () => (
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',
'M12 17h.01',
],
cancel: ['M18 6 6 18', 'M6 6l12 12'],
}
const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
<svg
className={styles.infoIcon}
className={styles.actionIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -112,9 +105,9 @@ const InfoIcon = () => (
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="11" x2="12" y2="16" />
<line x1="12" y1="8" x2="12.01" y2="8" />
{actionIconPaths[name].map((d) => (
<path key={d} d={d} />
))}
</svg>
)
@@ -262,7 +255,6 @@ export const OrderStatusView = ({
{error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}>
{status.items.map((item) => {
const cancelHint = cancelBlockedReason(item)
const banner = getItemBanner(item)
return (
<li key={item.id} className={styles.item}>
@@ -340,17 +332,13 @@ export const OrderStatusView = ({
aria-controls={`steps-${item.id}`}
onClick={() => toggleSteps(item.id)}
>
{expandedItemIds.has(item.id)
? 'Hide steps'
: 'Show steps'}
{expandedItemIds.has(item.id) ? 'Hide' : 'Steps'}
</button>
)}
</div>
)}
{item.etaSeconds !== null && (
<p className={styles.eta}>
{formatEtaMessage(item.etaSeconds)}
</p>
<EtaMessage etaSeconds={item.etaSeconds} />
)}
<div className={styles.actions}>
<button
@@ -360,6 +348,7 @@ export const OrderStatusView = ({
//
}}
>
<ActionIcon name="buyAgain" />
Buy again
</button>
@@ -375,7 +364,7 @@ export const OrderStatusView = ({
)
}
>
I picked this up
<ActionIcon name="pickedUp" />I picked this up
</button>
)}
<Link
@@ -386,32 +375,9 @@ export const OrderStatusView = ({
status: item.status,
}}
>
<ActionIcon name="help" />
Get help
</Link>
{cancelHint && (
<>
<button
type="button"
className={styles.infoButton}
popoverTarget={`cancel-hint-${item.id}`}
style={{ anchorName: `--cancel-hint-anchor-${item.id}` }}
>
<InfoIcon />
Why can't I cancel?
</button>
<p
id={`cancel-hint-${item.id}`}
popover="auto"
role="tooltip"
className={styles.cancelHint}
style={{
positionAnchor: `--cancel-hint-anchor-${item.id}`,
}}
>
{cancelHint}
</p>
</>
)}
{item.cancellable && (
<button
type="button"
@@ -425,6 +391,7 @@ export const OrderStatusView = ({
)
}
>
<ActionIcon name="cancel" />
Cancel
</button>
)}
-3
View File
@@ -789,8 +789,6 @@ export type OrderItemStatusView = {
menuItemName: string
status: schema.OrderItemStatus
cancelledBy: schema.OrderItemCancelledBy | null
// Total delay reported across the item's Steps so far.
delaySeconds: number
cancellable: boolean
steps: Array<{
sequence: number
@@ -1016,7 +1014,6 @@ export const getOrderStatus = async (
menuItemName: menuItem?.name ?? '',
status: item.status,
cancelledBy: item.cancelledBy,
delaySeconds: instances.reduce((sum, row) => sum + row.delaySeconds, 0),
cancellable:
item.status !== 'cancelled' &&
item.status !== 'ready' &&
+3 -3
View File
@@ -58,9 +58,9 @@ export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
const ABSOLUTE_TIME_THRESHOLD_SECONDS = 3 * 60 * 60
export const formatEtaMessage = (etaSeconds: number, now = new Date()) =>
export const formatEtaParts = (etaSeconds: number, now = new Date()) =>
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
? `Ready ${formatEtaClock(etaSeconds, now)}`
: `Ready in about ${formatDuration(etaSeconds)}`
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
: { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
export const formatStatus = (status: string) => status.replaceAll('_', ' ')