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
+123 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray } from 'drizzle-orm'
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
@@ -53,6 +53,7 @@ export type MenuItemWithRecipe = {
priceCents: number
steps: Array<{
sequence: number
name: string
machineTag: string
estimatedDurationSeconds: number
}>
@@ -71,6 +72,7 @@ export const getMenu = async (
.select({
menuItemId: schema.menuItemSteps.menuItemId,
sequence: schema.menuItemSteps.sequence,
name: schema.menuItemSteps.name,
machineTag: schema.machineTags.name,
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
})
@@ -94,8 +96,9 @@ export const getMenu = async (
priceCents: item.priceCents,
steps: steps
.filter((step) => step.menuItemId === item.id)
.map(({ sequence, machineTag, estimatedDurationSeconds }) => ({
.map(({ sequence, name, machineTag, estimatedDurationSeconds }) => ({
sequence,
name,
machineTag,
estimatedDurationSeconds,
})),
@@ -199,6 +202,7 @@ const initializeOrderItemRecipe = async (
liveSteps.map((step) => ({
orderItemId,
sequence: step.sequence,
name: step.name,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
@@ -582,6 +586,7 @@ export type OrderItemStatusView = {
cancellable: boolean
steps: Array<{
sequence: number
name: string
machineTag: string
estimatedDurationSeconds: number
status: 'done' | 'in_progress' | 'queued' | 'pending'
@@ -625,6 +630,7 @@ export const getOrderStatus = async (
.select({
orderItemId: schema.orderItemSteps.orderItemId,
sequence: schema.orderItemSteps.sequence,
name: schema.orderItemSteps.name,
machineTag: schema.machineTags.name,
machineTagId: schema.orderItemSteps.machineTagId,
estimatedDurationSeconds:
@@ -710,6 +716,7 @@ export const getOrderStatus = async (
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'pending' as const,
@@ -720,6 +727,7 @@ export const getOrderStatus = async (
if (instance.status === 'done') {
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'done' as const,
@@ -739,6 +747,7 @@ export const getOrderStatus = async (
}
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'in_progress' as const,
@@ -765,6 +774,7 @@ export const getOrderStatus = async (
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'queued' as const,
@@ -802,3 +812,114 @@ export const getOrderStatus = async (
items: itemViews,
}
}
export type CustomerOrderSummary = {
id: number
status: schema.OrderStatus
createdAt: Date
totalCents: number
items: Array<{ menuItemName: string; quantity: number }>
// Only set for a guest Order — its detail page needs this back as a
// search param to prove access, since there's no session to check.
guestAccessToken: string | null
}
const summarizeOrders = async (
db: Db,
orders: Array<typeof schema.orders.$inferSelect>,
): Promise<Array<CustomerOrderSummary>> => {
if (orders.length === 0) return []
const items = await db
.select({
orderId: schema.orderItems.orderId,
menuItemName: schema.menuItems.name,
priceCents: schema.orderItems.priceCents,
})
.from(schema.orderItems)
.innerJoin(
schema.menuItems,
eq(schema.orderItems.menuItemId, schema.menuItems.id),
)
.where(
inArray(
schema.orderItems.orderId,
orders.map((order) => order.id),
),
)
return orders.map((order) => {
const orderItems = items.filter((item) => item.orderId === order.id)
const quantityByMenuItemName = new Map<string, number>()
for (const item of orderItems) {
quantityByMenuItemName.set(
item.menuItemName,
(quantityByMenuItemName.get(item.menuItemName) ?? 0) + 1,
)
}
return {
id: order.id,
status: order.status,
createdAt: order.createdAt,
totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0),
items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({
menuItemName,
quantity,
})),
guestAccessToken: order.guestAccessToken,
}
})
}
// A signed-in customer's own Order history — guest Orders aren't included
// since they have no account to list under, only a per-Order access token
// (see getGuestOrderSummaries for how a guest's own history is looked up).
export const getOrdersForCustomer = async (
db: Db,
customerId: string,
): Promise<Array<CustomerOrderSummary>> => {
const orders = await db
.select()
.from(schema.orders)
.where(eq(schema.orders.customerId, customerId))
.orderBy(desc(schema.orders.createdAt))
return summarizeOrders(db, orders)
}
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
// A guest's Order history, looked up from the (orderId, guestAccessToken)
// pairs their browser remembered locally after each placement — there's no
// account to query by, so each ref's token stands in for one. A ref whose
// token doesn't match (stale, tampered, or the Order somehow isn't a guest
// Order) is silently dropped rather than failing the whole lookup.
export const getGuestOrderSummaries = async (
db: Db,
refs: Array<GuestOrderRef>,
): Promise<Array<CustomerOrderSummary>> => {
if (refs.length === 0) return []
const tokenByOrderId = new Map(
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
)
const orders = await db
.select()
.from(schema.orders)
.where(
inArray(schema.orders.id, [...tokenByOrderId.keys()]),
)
const authorizedOrders = orders.filter(
(order) =>
order.guestAccessToken !== null &&
tokenByOrderId.get(order.id) === order.guestAccessToken,
)
const summaries = await summarizeOrders(db, authorizedOrders)
return summaries.sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
)
}