Add the customer ordering page

Builds the main customer-facing flow: browse the Brand's menu, add
items to a cart, and check out either as a guest (name + contact) or,
if signed in, as the account customer — both call the existing
placeOrder server function, which already distinguishes the two. On
success, navigates to a new /orders/$orderId status page showing each
Order Item's Preparation Steps, queue position, ETA, and (per the
domain rules already established) a Cancel button while cancellable
and a pickup-confirmation button once ready.

Styled with SCSS Modules per CLAUDE.md; added the sass dependency and
the src/styles/ structure it describes (globals.scss imported once in
__root.tsx, alongside the existing Tailwind stylesheet the not-yet-
mounted header-user.tsx component depends on).

Verified end-to-end against a throwaway local Postgres: menu renders
with seeded items, cart quantities update, guest checkout places a
real order, and the status page reflects a real cancellation.

Also fixes CLAUDE.md's Commands section, which documented pnpm even
though this repo only has npm (package-lock.json, no pnpm installed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 20:31:32 +02:00
co-authored by Claude Sonnet 5
parent b7fdbd4779
commit ea459e72b3
19 changed files with 1118 additions and 24 deletions
@@ -0,0 +1,45 @@
@use '../../styles/variables' as *;
.section {
display: flex;
flex-direction: column;
gap: $space-2;
margin-top: $space-3;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
}
}
.submitButton {
padding: $space-2 $space-3;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react'
import { authClient } from '#/lib/auth-client.ts'
import { placeOrder } from '#/server/orders.ts'
import styles from './CheckoutSection.module.scss'
type Customer =
| { type: 'guest'; name: string; contact: string }
| { type: 'account' }
type PlacedOrder = { id: number; guestAccessToken: string | null }
type CheckoutSectionProps = {
items: Array<{ menuItemId: number }>
disabled: boolean
onPlaced: (order: PlacedOrder) => void
}
export const CheckoutSection = ({
items,
disabled,
onPlaced,
}: CheckoutSectionProps) => {
const { data: session, isPending } = authClient.useSession()
const [guestName, setGuestName] = useState('')
const [guestContact, setGuestContact] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (customer: Customer) => {
setSubmitting(true)
setError(null)
try {
const order = await placeOrder({ data: { items, customer } })
onPlaced(order)
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Could not place the order. Please try again.',
)
} finally {
setSubmitting(false)
}
}
if (isPending) return null
if (session) {
return (
<div className={styles.section}>
<button
type="button"
className={styles.submitButton}
disabled={disabled || submitting}
onClick={() => submit({ type: 'account' })}
>
{submitting ? 'Placing order…' : `Place order as ${session.user.name}`}
</button>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
return (
<form
className={styles.section}
onSubmit={(event) => {
event.preventDefault()
void submit({ type: 'guest', name: guestName, contact: guestContact })
}}
>
<label className={styles.field}>
Name
<input
value={guestName}
onChange={(event) => setGuestName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Email or phone
<input
value={guestContact}
onChange={(event) => setGuestContact(event.target.value)}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={disabled || submitting}
>
{submitting ? 'Placing order…' : 'Place order as guest'}
</button>
{error && <p className={styles.error}>{error}</p>}
</form>
)
}
@@ -0,0 +1,68 @@
@use '../../styles/variables' as *;
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.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;
}
.info {
flex: 1;
}
.name {
margin: 0 0 $space-1;
font-size: 1.1rem;
}
.description {
margin: 0 0 $space-1;
color: $color-text-muted;
font-size: 0.9rem;
}
.meta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
}
.quantity {
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;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.count {
min-width: 1.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
+68
View File
@@ -0,0 +1,68 @@
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import styles from './MenuList.module.scss'
type MenuListProps = {
menu: Array<MenuItemWithRecipe>
quantities: Record<number, number>
onAdd: (menuItemId: number) => void
onRemove: (menuItemId: number) => void
}
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatDuration = (seconds: number) => {
const minutes = Math.round(seconds / 60)
return minutes <= 1 ? '~1 min' : `~${minutes} min`
}
export const MenuList = ({
menu,
quantities,
onAdd,
onRemove,
}: MenuListProps) => (
<ul className={styles.list}>
{menu.map((item) => {
const quantity = quantities[item.id] ?? 0
const totalSeconds = item.steps.reduce(
(sum, step) => sum + step.estimatedDurationSeconds,
0,
)
return (
<li key={item.id} className={styles.item}>
<div className={styles.info}>
<h3 className={styles.name}>{item.name}</h3>
{item.description && (
<p className={styles.description}>{item.description}</p>
)}
<p className={styles.meta}>
{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>
<span className={styles.count}>{quantity}</span>
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</li>
)
})}
</ul>
)
@@ -0,0 +1,35 @@
@use '../../styles/variables' as *;
.empty {
color: $color-text-muted;
font-size: 0.9rem;
}
.summary {
display: flex;
flex-direction: column;
gap: $space-2;
}
.lines {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-1;
}
.line {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.total {
display: flex;
justify-content: space-between;
font-weight: 600;
padding-top: $space-2;
border-top: 1px solid $color-border;
}
+46
View File
@@ -0,0 +1,46 @@
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import styles from './OrderSummary.module.scss'
type OrderSummaryProps = {
menu: Array<MenuItemWithRecipe>
quantities: Record<number, number>
}
export const OrderSummary = ({ menu, quantities }: OrderSummaryProps) => {
const lines = menu
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
.filter(({ quantity }) => quantity > 0)
if (lines.length === 0) {
return (
<p className={styles.empty}>
Your order is empty — add something from the menu.
</p>
)
}
const totalCents = lines.reduce(
(sum, { item, quantity }) => sum + item.priceCents * quantity,
0,
)
return (
<div className={styles.summary}>
<ul className={styles.lines}>
{lines.map(({ item, quantity }) => (
<li key={item.id} className={styles.line}>
<span>
{quantity} × {item.name}
</span>
<span>${((item.priceCents * quantity) / 100).toFixed(2)}</span>
</li>
))}
</ul>
<div className={styles.total}>
<span>Total</span>
<span>${(totalCents / 100).toFixed(2)}</span>
</div>
</div>
)
}