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:
+123
-2
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user