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