Add guest/account auth flows, order tracking, and order history UI

- Wire up Better Auth (email/password + Google/Apple SSO) with a
  sign-in/sign-up UI, header account menu, and a basket that follows
  the signed-in-or-guest state.
- Give the order status page a vertical timeline per preparation step
  (with a distinct customer-facing name separate from the machine tag,
  a done/in-progress/queued/pending look, and a sonar-ping highlight on
  the active step), a formatted ETA (relative under 3h, absolute clock
  time beyond that), and Buy again / Get help / Cancel actions.
- Expand Pizza's recipe into 6 steps across a shared prep station and
  the existing oven/assembly stations, with matching schema, seed, and
  domain changes plus updated/added test coverage.
- Add an Orders list (account orders via session, guest orders via a
  locally remembered access-token list) with a shared getOrdersForCustomer
  domain summary, and remember a guest's name/contact after their first
  order so checkout doesn't ask again.
- Improve page transitions with the View Transitions API: a shared
  element morph between an order card and its detail page, a stable
  (non-jumping) header across navigations, and reduced-motion handling.
- Add a Ghost Kitchen wordmark (Space Grotesk, faded "Ghost", ghost
  icon standing in for the "o") and shrink the basket icon to match
  the rest of the header controls.
- Add a run-ghost-kitchen agent skill documenting local setup, the
  dev-server driver, and the gotchas hit along the way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 11:58:24 +02:00
co-authored by Claude Sonnet 5
parent 5059f40d4c
commit 79bd64ce8c
54 changed files with 3514 additions and 301 deletions
@@ -43,3 +43,18 @@
font-size: 0.85rem;
margin: 0;
}
.forgetButton {
align-self: center;
padding: 0;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.8rem;
text-decoration: underline;
cursor: pointer;
&:hover {
color: $color-text;
}
}
+58 -1
View File
@@ -1,6 +1,13 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { authClient } from '#/lib/auth-client.ts'
import {
clearGuestIdentity,
getGuestIdentity,
saveGuestIdentity,
} from '#/lib/guestIdentity.ts'
import type { GuestIdentity } from '#/lib/guestIdentity.ts'
import { addGuestOrderRef } from '#/lib/guestOrders.ts'
import { placeOrder } from '#/server/orders.ts'
import styles from './CheckoutSection.module.scss'
@@ -25,14 +32,36 @@ export const CheckoutSection = ({
const { data: session, isPending } = authClient.useSession()
const [guestName, setGuestName] = useState('')
const [guestContact, setGuestContact] = useState('')
const [rememberedGuest, setRememberedGuest] = useState<GuestIdentity | null>(
null,
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Read after mount, not during initial render, so the server-rendered and
// first client-rendered HTML match (no localStorage on the server) and
// React doesn't flag a hydration mismatch — same reasoning as
// BasketProvider.
useEffect(() => {
setRememberedGuest(getGuestIdentity())
}, [])
const submit = async (customer: Customer) => {
setSubmitting(true)
setError(null)
try {
const order = await placeOrder({ data: { items, customer } })
if (order.guestAccessToken) {
addGuestOrderRef({
orderId: order.id,
guestAccessToken: order.guestAccessToken,
})
}
if (customer.type === 'guest') {
const identity = { name: customer.name, contact: customer.contact }
saveGuestIdentity(identity)
setRememberedGuest(identity)
}
onPlaced(order)
} catch (err) {
setError(
@@ -63,6 +92,34 @@ export const CheckoutSection = ({
)
}
if (rememberedGuest) {
return (
<div className={styles.section}>
<button
type="button"
className={styles.submitButton}
disabled={disabled || submitting}
onClick={() => submit({ type: 'guest', ...rememberedGuest })}
>
{submitting
? 'Placing order…'
: `Place order as ${rememberedGuest.name}`}
</button>
<button
type="button"
className={styles.forgetButton}
onClick={() => {
clearGuestIdentity()
setRememberedGuest(null)
}}
>
Not you?
</button>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
return (
<form
className={styles.section}
@@ -54,6 +54,8 @@
border: 1px solid $color-border;
background: $color-background;
cursor: pointer;
align-self: center;
justify-self: center;
&:disabled {
opacity: 0.4;
@@ -1,35 +0,0 @@
@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
@@ -1,46 +0,0 @@
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>
)
}