Add menu item customization/detail pages and configurable currency formatting

Lets customers pick options on a menu item detail page before adding to the
basket, and makes displayed prices respect a configurable currency/locale
via VITE_CURRENCY/VITE_LOCALE. Also crossfades the basket's empty/filled
states with AnimatePresence in wait mode instead of animating both at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:00:45 +02:00
co-authored by Claude Sonnet 5
parent e98b0d86d2
commit d66e09f515
38 changed files with 2099 additions and 229 deletions
@@ -31,6 +31,11 @@
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
+1 -1
View File
@@ -19,7 +19,7 @@ type Customer =
type PlacedOrder = { id: number; guestAccessToken: string | null }
type CheckoutSectionProps = {
items: Array<{ menuItemId: number }>
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
disabled: boolean
onPlaced: (order: PlacedOrder) => void
}
@@ -0,0 +1,82 @@
@use '../../styles/variables' as *;
.customizer {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.heading {
margin: 0;
font-size: 1rem;
}
.panel {
display: flex;
flex-direction: column;
gap: $space-2;
}
.option {
display: flex;
align-items: center;
gap: $space-2;
font-size: 0.9rem;
cursor: pointer;
span:first-of-type {
flex: 1;
}
}
.delta {
color: $color-text-muted;
font-size: 0.8rem;
}
.stepperRow {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: $space-3;
border-top: 1px solid $color-border;
}
.price {
font-weight: 600;
}
.stepper {
display: flex;
align-items: center;
gap: $space-2;
}
.stepperButton {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-background;
cursor: pointer;
transition: border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.count {
min-width: 1.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import type { BasketLine } from '#/lib/basket.tsx'
import { formatPrice, formatPriceDelta } from '#/lib/format.ts'
import styles from './MenuItemCustomizer.module.scss'
type MenuItemCustomizerProps = {
item: MenuItemWithRecipe
// Every basket line for this item, including the plain no-options one —
// the stepper below tracks whichever combination is currently checked.
lines: Array<BasketLine>
onAdd: (menuItemId: number, optionIds: Array<number>) => void
onRemove: (menuItemId: number, optionIds: Array<number>) => void
}
const sortedKey = (optionIds: Array<number>) =>
[...optionIds].sort((a, b) => a - b).join(',')
export const MenuItemCustomizer = ({
item,
lines,
onAdd,
onRemove,
}: MenuItemCustomizerProps) => {
const [checkedOptionIds, setCheckedOptionIds] = useState<Array<number>>([])
const toggleOption = (optionId: number) =>
setCheckedOptionIds((prev) =>
prev.includes(optionId)
? prev.filter((id) => id !== optionId)
: [...prev, optionId],
)
// The one stepper on this page always reflects whatever combination of
// options is currently checked — checking different boxes just points it
// at a different (possibly already-existing) basket line.
const currentLine = lines.find(
(line) => sortedKey(line.optionIds) === sortedKey(checkedOptionIds),
)
const quantity = currentLine?.quantity ?? 0
const unitPriceCents =
item.priceCents +
checkedOptionIds.reduce(
(sum, id) =>
sum + (item.options.find((option) => option.id === id)?.priceDeltaCents ?? 0),
0,
)
return (
<div className={styles.customizer}>
<h2 className={styles.heading}>Customize</h2>
<div className={styles.panel}>
{item.options.map((option) => (
<label key={option.id} className={styles.option}>
<input
type="checkbox"
checked={checkedOptionIds.includes(option.id)}
onChange={() => toggleOption(option.id)}
/>
<span>{option.name}</span>
{formatPriceDelta(option.priceDeltaCents) && (
<span className={styles.delta}>
{formatPriceDelta(option.priceDeltaCents)}
</span>
)}
</label>
))}
</div>
<div className={styles.stepperRow}>
<span className={styles.price}>{formatPrice(unitPriceCents)}</span>
<div className={styles.stepper}>
<button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id, checkedOptionIds)}
disabled={quantity === 0}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<AnimatedNumber value={quantity} className={styles.count} />
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id, checkedOptionIds)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
@use '../../styles/variables' as *;
.detail {
display: flex;
flex-direction: column;
gap: $space-4;
}
.info {
display: flex;
flex-direction: column;
}
.name {
margin: 0 0 $space-1;
}
.description {
margin: 0 0 $space-1;
color: $color-text-muted;
}
.meta {
margin: 0;
color: $color-text-muted;
font-size: 0.9rem;
}
@@ -0,0 +1,54 @@
import { MenuItemCustomizer } from '#/components/ordering/MenuItemCustomizer.tsx'
import { formatDuration } from '#/components/ordering/MenuList.tsx'
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import type { BasketLine } from '#/lib/basket.tsx'
import { formatPrice } from '#/lib/format.ts'
import styles from './MenuItemDetail.module.scss'
type MenuItemDetailProps = {
item: MenuItemWithRecipe
lines: Array<BasketLine>
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
}
export const MenuItemDetail = ({
item,
lines,
onAdd,
onRemove,
}: MenuItemDetailProps) => {
const totalSeconds = item.steps.reduce(
(sum, step) => sum + step.estimatedDurationSeconds,
0,
)
return (
<div className={styles.detail}>
<MenuItemThumbnail
name={item.name}
size="hero"
style={{ viewTransitionName: `menu-item-${item.id}` }}
/>
<div className={styles.info}>
<h1 className={styles.name}>{item.name}</h1>
{item.description && (
<p className={styles.description}>{item.description}</p>
)}
<p className={styles.meta}>
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p>
</div>
<MenuItemCustomizer
item={item}
lines={lines}
onAdd={onAdd}
onRemove={onRemove}
/>
</div>
)
}
@@ -0,0 +1,37 @@
@use '../../styles/variables' as *;
.thumbnail {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
border-radius: $radius;
overflow: hidden;
background: $color-background;
color: $color-text-muted;
svg {
height: 1.75rem;
width: 1.75rem;
}
}
.hero {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
border-radius: $radius;
svg {
height: 3rem;
width: 3rem;
}
}
.photo {
width: 100%;
height: 100%;
object-fit: cover;
}
@@ -0,0 +1,57 @@
import type { CSSProperties } from 'react'
import styles from './MenuItemThumbnail.module.scss'
type MenuItemThumbnailProps = {
name: string
size?: 'small' | 'hero'
style?: CSSProperties
}
const UtensilsIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 3v7a2 2 0 0 0 2 2v9" />
<path d="M7 3v4M11 3v4" />
<path d="M17 3c-1.5 0-2.5 2-2.5 5s1 5 1.5 5v9" />
</svg>
)
const photoByKeyword: Array<{ keyword: string; src: string }> = [
{ keyword: 'pizza', src: '/pizza.png' },
{ keyword: 'burger', src: '/burger.png' },
]
// Falls back to a simple icon for any menu item that isn't Pizza or Burger,
// since there's no photo for it yet.
const photoFor = (name: string) => {
const lower = name.toLowerCase()
return photoByKeyword.find((entry) => lower.includes(entry.keyword))?.src
}
export const MenuItemThumbnail = ({
name,
size = 'small',
style,
}: MenuItemThumbnailProps) => {
const src = photoFor(name)
return (
<div
className={`${styles.thumbnail} ${size === 'hero' ? styles.hero : ''}`}
style={style}
aria-hidden="true"
>
{src ? (
<img src={src} alt="" className={styles.photo} />
) : (
<UtensilsIcon />
)}
</div>
)
}
+36 -4
View File
@@ -10,16 +10,42 @@
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
}
.main {
display: flex;
align-items: center;
gap: $space-3;
flex: 1;
min-width: 0;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: none;
&:is(a, button) {
cursor: pointer;
&:hover .name {
color: $color-primary;
}
}
}
.info {
flex: 1;
}
@@ -27,6 +53,7 @@
.name {
margin: 0 0 $space-1;
font-size: 1.1rem;
transition: color 0.15s ease;
}
.description {
@@ -56,6 +83,11 @@
cursor: pointer;
align-self: center;
justify-self: center;
transition: border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
}
&:disabled {
opacity: 0.4;
+61 -35
View File
@@ -1,38 +1,43 @@
import { Link } from '@tanstack/react-router'
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import type { BasketLine } from '#/lib/basket.tsx'
import { formatPrice } from '#/lib/format.ts'
import styles from './MenuList.module.scss'
type MenuListProps = {
menu: Array<MenuItemWithRecipe>
quantities: Record<number, number>
onAdd: (menuItemId: number) => void
onRemove: (menuItemId: number) => void
lines: Array<BasketLine>
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
}
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatDuration = (seconds: number) => {
export const formatDuration = (seconds: number) => {
const minutes = Math.round(seconds / 60)
return minutes <= 1 ? '~1 min' : `~${minutes} min`
return minutes <= 1 ? '1 min' : `${minutes} min`
}
export const MenuList = ({
menu,
quantities,
onAdd,
onRemove,
}: MenuListProps) => (
export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
<ul className={styles.list}>
{menu.map((item) => {
const quantity = quantities[item.id] ?? 0
const defaultLine = lines.find(
(line) => line.menuItemId === item.id && line.optionIds.length === 0,
)
const hasOptions = item.options.length > 0
const totalSeconds = item.steps.reduce(
(sum, step) => sum + step.estimatedDurationSeconds,
0,
)
return (
<li key={item.id} className={styles.item}>
const info = (
<>
<MenuItemThumbnail
name={item.name}
style={{ viewTransitionName: `menu-item-${item.id}` }}
/>
<div className={styles.info}>
<h3 className={styles.name}>{item.name}</h3>
{item.description && (
@@ -42,25 +47,46 @@ export const MenuList = ({
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p>
</div>
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id)}
disabled={quantity === 0}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<AnimatedNumber value={quantity} className={styles.count} />
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</>
)
return (
<li key={item.id} className={styles.item}>
<div className={styles.row}>
{hasOptions ? (
<Link
to="/items/$menuItemId"
params={{ menuItemId: String(item.id) }}
className={styles.main}
>
{info}
</Link>
) : (
<div className={styles.main}>{info}</div>
)}
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id)}
disabled={(defaultLine?.quantity ?? 0) === 0}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<AnimatedNumber
value={defaultLine?.quantity ?? 0}
className={styles.count}
/>
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</div>
</li>
)