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:
+131
-11
@@ -51,6 +51,15 @@ const placeGuestOrder = (items: Array<{ menuItemId: number }>) =>
|
||||
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
||||
})
|
||||
|
||||
const placeAccountOrder = (
|
||||
items: Array<{ menuItemId: number }>,
|
||||
customerId: string,
|
||||
) =>
|
||||
ordering.placeOrder(testDb.db, payment, {
|
||||
items,
|
||||
customer: { type: 'account', customerId },
|
||||
})
|
||||
|
||||
// Tests act as the guest who placed the order, so they present its access
|
||||
// token exactly like a real caller would — this exercises the same
|
||||
// ownership check a stranger guessing the order id would fail.
|
||||
@@ -67,13 +76,58 @@ describe('menu', () => {
|
||||
|
||||
const pizza = menu.find((item) => item.name === 'Pizza')
|
||||
expect(pizza?.steps).toEqual([
|
||||
{ sequence: 1, machineTag: 'oven', estimatedDurationSeconds: 600 },
|
||||
{
|
||||
sequence: 1,
|
||||
name: 'Flattening dough',
|
||||
machineTag: 'prep',
|
||||
estimatedDurationSeconds: 30,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
name: 'Spreading sauce',
|
||||
machineTag: 'prep',
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
{
|
||||
sequence: 3,
|
||||
name: 'Adding toppings',
|
||||
machineTag: 'prep',
|
||||
estimatedDurationSeconds: 30,
|
||||
},
|
||||
{
|
||||
sequence: 4,
|
||||
name: 'Baking in the oven',
|
||||
machineTag: 'oven',
|
||||
estimatedDurationSeconds: 600,
|
||||
},
|
||||
{
|
||||
sequence: 5,
|
||||
name: 'Slicing',
|
||||
machineTag: 'assembly',
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
{
|
||||
sequence: 6,
|
||||
name: 'Packaging',
|
||||
machineTag: 'assembly',
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
])
|
||||
|
||||
const burger = menu.find((item) => item.name === 'Burger')
|
||||
expect(burger?.steps).toEqual([
|
||||
{ sequence: 1, machineTag: 'grill', estimatedDurationSeconds: 240 },
|
||||
{ sequence: 2, machineTag: 'assembly', estimatedDurationSeconds: 90 },
|
||||
{
|
||||
sequence: 1,
|
||||
name: 'Grilling patty',
|
||||
machineTag: 'grill',
|
||||
estimatedDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
name: 'Assembling burger',
|
||||
machineTag: 'assembly',
|
||||
estimatedDurationSeconds: 90,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -98,8 +152,8 @@ describe('placing an order', () => {
|
||||
const pizzaItem = status.items.find(
|
||||
(item) => item.menuItemName === 'Pizza',
|
||||
)!
|
||||
// Only one oven, but the pizza is the only thing waiting for it, so it
|
||||
// starts immediately.
|
||||
// Only one prep station, but the pizza is the only thing waiting for
|
||||
// it, so its first Step starts immediately.
|
||||
expect(pizzaItem.steps[0].status).toBe('in_progress')
|
||||
expect(pizzaItem.cancellable).toBe(false)
|
||||
|
||||
@@ -192,7 +246,7 @@ describe('placing an order', () => {
|
||||
|
||||
describe('cancelling an Order Item', () => {
|
||||
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
@@ -205,7 +259,7 @@ describe('cancelling an Order Item', () => {
|
||||
const burgerItem = before.items.find(
|
||||
(item) => item.menuItemName === 'Burger',
|
||||
)!
|
||||
expect(pizzaItem.status).toBe('paid') // still queued, oven is busy
|
||||
expect(pizzaItem.status).toBe('paid') // still queued, prep station is busy
|
||||
expect(burgerItem.steps[0].status).toBe('in_progress') // grill was free
|
||||
|
||||
await ordering.cancelOrderItem(
|
||||
@@ -231,7 +285,7 @@ describe('cancelling an Order Item', () => {
|
||||
})
|
||||
|
||||
it('completes the order once its only item is cancelled', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
@@ -279,7 +333,7 @@ describe('cancelling an Order Item', () => {
|
||||
})
|
||||
|
||||
it('recharges the customer if the DB transaction fails after the refund has already gone through', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
@@ -319,7 +373,7 @@ describe('cancelling an Order Item', () => {
|
||||
it('surfaces the original cancellation failure even if the compensating re-charge also fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
@@ -417,8 +471,13 @@ describe('advancing steps and pickup', () => {
|
||||
|
||||
await ordering.advanceOrderItemStep(testDb.db, firstItemId)
|
||||
|
||||
// The freed prep station goes to whichever queued Step has been
|
||||
// waiting longest for that Tag — the second order's first Step, which
|
||||
// was already queued, rather than the first order's own next Step,
|
||||
// which only just joined the queue.
|
||||
firstStatus = await getStatus(firstOrder)
|
||||
expect(firstStatus.items[0].status).toBe('ready')
|
||||
expect(firstStatus.items[0].steps[0].status).toBe('done')
|
||||
expect(firstStatus.items[0].steps[1].status).toBe('queued')
|
||||
|
||||
secondStatus = await getStatus(secondOrder)
|
||||
expect(secondStatus.items[0].steps[0].status).toBe('in_progress')
|
||||
@@ -498,3 +557,64 @@ describe('access control', () => {
|
||||
).rejects.toThrow(ordering.ForbiddenError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('order history', () => {
|
||||
it("lists a signed-in customer's own Orders, most recent first, with per-item quantities, total, and no guest access token", async () => {
|
||||
await placeAccountOrder(
|
||||
[{ menuItemId: seed.menuItems.burger.id }],
|
||||
'test-customer-1',
|
||||
)
|
||||
await placeAccountOrder(
|
||||
[
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
],
|
||||
'test-customer-1',
|
||||
)
|
||||
|
||||
const orders = await ordering.getOrdersForCustomer(
|
||||
testDb.db,
|
||||
'test-customer-1',
|
||||
)
|
||||
|
||||
expect(orders).toHaveLength(2)
|
||||
expect(orders[0].items).toEqual([{ menuItemName: 'Pizza', quantity: 2 }])
|
||||
expect(orders[0].totalCents).toBe(seed.menuItems.pizza.priceCents * 2)
|
||||
expect(orders[0].guestAccessToken).toBeNull()
|
||||
expect(orders[1].items).toEqual([{ menuItemName: 'Burger', quantity: 1 }])
|
||||
})
|
||||
|
||||
it("does not include another customer's Orders", async () => {
|
||||
await placeAccountOrder(
|
||||
[{ menuItemId: seed.menuItems.burger.id }],
|
||||
'someone-else',
|
||||
)
|
||||
|
||||
const orders = await ordering.getOrdersForCustomer(
|
||||
testDb.db,
|
||||
'test-customer-1',
|
||||
)
|
||||
expect(orders).toEqual([])
|
||||
})
|
||||
|
||||
it("looks up a guest's Orders by their remembered (orderId, token) pairs, skipping any with the wrong token", async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
])
|
||||
|
||||
const withCorrectToken = await ordering.getGuestOrderSummaries(
|
||||
testDb.db,
|
||||
[{ orderId: order.id, guestAccessToken: order.guestAccessToken! }],
|
||||
)
|
||||
expect(withCorrectToken).toHaveLength(1)
|
||||
expect(withCorrectToken[0].guestAccessToken).toBe(order.guestAccessToken)
|
||||
expect(withCorrectToken[0].items).toEqual([
|
||||
{ menuItemName: 'Burger', quantity: 1 },
|
||||
])
|
||||
|
||||
const withWrongToken = await ordering.getGuestOrderSummaries(testDb.db, [
|
||||
{ orderId: order.id, guestAccessToken: 'not-the-real-token' },
|
||||
])
|
||||
expect(withWrongToken).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user