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