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