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
+148
View File
@@ -0,0 +1,148 @@
---
name: run-ghost-kitchen
description: Build, run, and drive Ghost Kitchen (TanStack Start ordering app). Use when asked to start the app, run its tests, seed its database, take a screenshot of its UI, or interact with the running app (menu, basket, sign-in/sign-up).
---
Ghost Kitchen is a TanStack Start web app (Vite dev server on port 3000,
Postgres via Docker, Better Auth). There is no `chromium-cli` binary in
this environment — drive it with the `mcp__claude-in-chrome__*` tools
(load them via `ToolSearch` if deferred; see the `claude-in-chrome`
skill). All paths below are relative to the repo root.
## Prerequisites
Docker (for Postgres) and Node — both already present in this
environment. No OS packages were needed.
## Setup
One-time, per fresh checkout / fresh Postgres volume:
```bash
docker compose up -d # starts Postgres on localhost:5433
cp .env.example .env.local
```
`BETTER_AUTH_SECRET` in `.env.example` ships empty — Better Auth needs a
real value or auth routes fail. Generate one and fill it in:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# paste the output as BETTER_AUTH_SECRET= in .env.local
```
Then apply migrations and seed data:
```bash
npm run db:migrate # this project's Drizzle migrations
npm run auth:migrate # Better Auth's own tables (users, sessions, ...)
npm run db:seed # sample Brand, menu, Machines — SKIP if DB already seeded, see Gotchas
```
Against a genuinely empty database, `auth:migrate` prompts interactively
("Are you sure you want to run these migrations?"). Non-interactively,
answer it with `yes | npm run auth:migrate`.
## Build
No separate build step for local dev (Vite serves TS directly). For a
production bundle: `npm run build`.
## Run (agent path)
Start the dev server in the background and wait for it to actually serve
(don't `sleep` — poll the port):
```bash
npm run dev &
timeout 30 bash -c 'until curl -sf http://localhost:3000 >/dev/null; do sleep 1; done'
```
Stop it with `lsof -ti:3000 -sTCP:LISTEN | xargs -r kill` before
relaunching, or the next run hits `EADDRINUSE`. Check for an
already-running instance first (`lsof -i :3000 -sTCP:LISTEN`) — in this
project's dev container a server is often already up from a prior
session; reuse it instead of starting a duplicate.
Drive it with the `mcp__claude-in-chrome__*` tools:
```
tabs_context_mcp { createIfEmpty: true }
navigate { url: "http://localhost:3000", tabId: <tab> }
computer { action: "screenshot", tabId: <tab> }
```
One representative flow that proves the app works end-to-end (menu ->
basket -> sign-up):
```
# Menu page: bump a quantity stepper
find { query: "increment button for Pizza", tabId: <tab> }
computer { action: "left_click", coordinate: [<x>,<y>], tabId: <tab> }
# Basket icon (top-right) -> /basket shows line items + total
computer { action: "left_click", coordinate: [1338, 29], tabId: <tab> } # icon position at 1400px width
# Sign up (/signup): Name, Email, Password fields -> "Create account"
navigate { url: "http://localhost:3000/signup", tabId: <tab> }
# fill Name/Email/Password via computer left_click + type, then click "Create account"
```
Close tabs you created when done (`tabs_close_mcp`).
## Run (human path)
`npm run dev` -> serves on http://localhost:3000, Ctrl-C to stop. Useless
headless without a browser to point at it.
## Test
```bash
npm test # vitest run — 27 tests, 2 files, ~3s
npm run typecheck
npm run lint
```
---
## Gotchas
- **`.env.local` doesn't exist by default** and isn't created by any
script — `cp .env.example .env.local` is a required manual step, and
`BETTER_AUTH_SECRET` inside it ships blank. Leaving it blank breaks
sign-in/sign-up silently at the Better Auth layer.
- **`npm run db:seed` is not idempotent.** Re-running it against an
already-seeded DB throws `DrizzleQueryError: duplicate key value
violates unique constraint "machine_tags_name_idx"` and exits 1. If
Postgres already has data (e.g. a long-lived dev container), skip
seeding rather than treating the error as a setup failure.
- **Port 3000 is often already occupied** by a dev server started
outside the current session (e.g. the user's own terminal). Check
`lsof -i :3000 -sTCP:LISTEN` before launching — if something already
answers `curl -sf http://localhost:3000`, drive that instead of
fighting over the port.
- **Session/basket state persists across browser tab reloads** (basket
contents and signed-in user both survived a fresh tab + navigate in
testing) — a new tab hitting `localhost:3000` may already show a
logged-in user or a non-empty basket from a prior run in the same
container. Not a bug; just don't assume a fresh tab means fresh state.
- **`[TanStack Devtools] Failed to load: TypeError: Failed to fetch
dynamically imported module ...`** shows up in the console on first
load. It's a stale Vite dep-cache issue with the devtools panel, not
an app error — harmless, ignore it (verified via
`read_console_messages`, the rest of the app functions normally).
- **Adding a `NOT NULL` column via `db:generate` against a populated
local DB** makes `drizzle-kit generate` prompt interactively for a
default value — awkward to script. Reset the Postgres volume first
(`docker compose down -v && docker compose up -d`, then recreate the
DB, migrate, `yes | npm run auth:migrate`, reseed) so the column is
added to an empty table and no prompt appears.
## Troubleshooting
- **`Error: Port 3000 is already in use` on `npm run dev`**: another
process already holds it — see the port gotcha above. Either reuse it
(`curl` returns 200) or `lsof -ti:3000 -sTCP:LISTEN | xargs -r kill`
first if you own that process.
- **`duplicate key value violates unique constraint` from `db:seed`**:
DB already seeded — skip the seed step, don't debug it.
+9
View File
@@ -1 +1,10 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=
# Optional: enables "Continue with Google" / "Continue with Apple" on the
# sign-in and sign-up pages. Leave unset to fall back to email/password only.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
+36 -3
View File
@@ -69,14 +69,47 @@ npm run db:migrate
## Setting up Better Auth
Generate and set the `BETTER_AUTH_SECRET` environment variable in your
`.env.local`:
`.env.local`, and make sure `BETTER_AUTH_URL` matches the URL the app is
actually served from (`http://localhost:3000` in local dev):
```bash
npx @better-auth/cli secret
```
See the [Better Auth documentation](https://www.better-auth.com) for more on
configuring authentication.
Account creation and sign-in live at `/signup` and `/login`, supporting both
email/password and "Continue with Google" / "Continue with Apple" SSO. The
SSO buttons only appear once the corresponding provider's credentials are
set in `.env.local`:
```bash
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
APPLE_CLIENT_ID=...
APPLE_CLIENT_SECRET=...
```
Create OAuth credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
and the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list/serviceId)
respectively, with the redirect URI `{BETTER_AUTH_URL}/api/auth/callback/google`
(swap `google` for `apple`). See the
[Better Auth documentation](https://www.better-auth.com) for more on
configuring authentication, and its
[Google](https://www.better-auth.com/docs/authentication/google) and
[Apple](https://www.better-auth.com/docs/authentication/apple) provider
guides for provider-specific setup details.
For Google, `scripts/setup-google-sso.sh` walks you through the Google Cloud
Console steps interactively and writes the resulting `GOOGLE_CLIENT_ID` /
`GOOGLE_CLIENT_SECRET` into `.env` for you:
```bash
./scripts/setup-google-sso.sh
```
Apple's Sign in with Apple doesn't support `localhost` redirect URIs — you
need a real HTTPS domain (a deployment or a tunnel like ngrok) registered on
the Services ID before it's testable, so there's no equivalent script for it
yet.
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "menu_item_steps" ADD COLUMN "name" text NOT NULL;--> statement-breakpoint
ALTER TABLE "order_item_steps" ADD COLUMN "name" text NOT NULL;
+602
View File
@@ -0,0 +1,602 @@
{
"id": "3761e215-1f2d-4374-83af-942ef92e41df",
"prevId": "aecfb37f-fb88-44ba-8e02-f290a3929293",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -22,6 +22,13 @@
"when": 1788976393301,
"tag": "0002_large_lake",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1789028563859,
"tag": "0003_soft_psynapse",
"breakpoints": true
}
]
}
+40 -3
View File
@@ -13,6 +13,7 @@
"better-auth": "^1.5.3",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1",
"framer-motion": "^13.2.0",
"pg": "^8.16.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -7934,6 +7935,29 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/framer-motion": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.2.0.tgz",
"integrity": "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^13.2.0",
"motion-utils": "^13.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -8962,6 +8986,21 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/motion-dom": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.2.0.tgz",
"integrity": "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^13.0.0"
}
},
"node_modules/motion-utils": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz",
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10610,9 +10649,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.13",
+1
View File
@@ -32,6 +32,7 @@
"better-auth": "^1.5.3",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1",
"framer-motion": "^13.2.0",
"pg": "^8.16.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
#
# A wizard walks a human through a manual procedure, step by step.
# Generated by the /wizard skill.
#
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
# it. Author the per-step stages below the marker.
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# Wizard library: delightful, consistent UX, identical across every wizard.
# ──────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
else
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
fi
# Author sets this at the top of the stages section.
TOTAL_STAGES=0
_STAGE_INDEX=0
ENV_FILE="${ENV_FILE:-.env}"
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
WRITTEN_SECRET=() # secret NAMEs set this run
SKIPPED=() # things we couldn't do (e.g. gh missing)
# _clear wipes the terminal so only the current step is on screen. No-op when
# output isn't a terminal, so piped logs stay readable.
_clear() {
[[ -t 1 ]] || return 0
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
}
# banner "Title" shows the opening frame: what this wizard does.
banner() {
_clear
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET"
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
printf ' later, since it remembers values already saved.%s\n' "$RESET"
pause "Ready to start?"
}
# stage "Name" clears the screen, then announces a stage and shows progress.
# Clearing keeps only the current step on screen.
stage() {
_clear
_STAGE_INDEX=$((_STAGE_INDEX + 1))
printf '\n%s%s▸ Stage %s/%s · %s%s\n' \
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET"
}
# say "..." prints a plain instruction line.
say() { printf ' %s\n' "$1"; }
# step "..." is a numbered-feeling action the human takes in the browser.
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
# open_url URL opens it in the human's browser, cross-platform incl. WSL.
open_url() {
local url="$1"
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
elif command -v open >/dev/null 2>&1; then open "$url"
else warn "couldn't open a browser; visit it manually: $url"; fi
} >/dev/null 2>&1 || warn "couldn't open a browser, so visit it manually: $url"
}
# pause "msg" waits for the human to confirm they've done the manual part.
pause() {
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
read -r _ || true
}
# confirm "question" is a y/N gate; returns success on yes.
confirm() {
local reply=""
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
read -r reply || true
[[ "$reply" =~ ^[Yy] ]]
}
# _existing KEY: current value of KEY in ENV_FILE, if any.
_existing() {
[[ -f "$ENV_FILE" ]] || return 1
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
printf '%s' "${line#*=}"
}
# ask KEY "Prompt" reads a value into $KEY. Offers the existing .env value as
# a default on re-runs (Enter keeps it). Visible input (non-secret).
ask() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -r input || true
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# ask_secret KEY "Prompt" is like ask, but input is hidden.
ask_secret() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -rs input || true
printf '\n'
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# write_env KEY VALUE upserts KEY=VALUE into ENV_FILE (creates it; replaces
# any existing line). Idempotent.
write_env() {
local key="$1" value="$2" tmp
touch "$ENV_FILE"
tmp=$(mktemp)
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
printf '%s=%s\n' "$key" "$value" >> "$tmp"
mv "$tmp" "$ENV_FILE"
WRITTEN_ENV+=("$key")
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
}
# set_secret NAME VALUE sets a GitHub Actions repo secret via gh. Falls back
# to a warning (and records it) if gh is unavailable or unauthenticated.
set_secret() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
WRITTEN_SECRET+=("$name")
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
warn "skipped GitHub secret $name: gh not ready; set it later"
}
# set_var NAME VALUE sets a GitHub Actions repo variable (non-secret).
set_var() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub variable $name")
warn "skipped GitHub variable $name, gh not ready; set it later"
}
# finish clears, then shows a closing summary of everything configured.
finish() {
_clear
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
if (( ${#SKIPPED[@]} )); then
printf '\n'; warn "still to do by hand:"
for s in "${SKIPPED[@]}"; do note " - $s"; done
fi
printf '\n'
}
# ──────────────────────────────────────────────────────────────────────────
# STAGES: author this section. One stage() per step the human takes.
# Replace the example below. Set TOTAL_STAGES to match the stages you write.
# ──────────────────────────────────────────────────────────────────────────
TOTAL_STAGES=2
banner "Google Sign-In setup"
BETTER_AUTH_URL_VALUE=$(_existing BETTER_AUTH_URL || true)
BETTER_AUTH_URL_VALUE="${BETTER_AUTH_URL_VALUE:-http://localhost:3000}"
REDIRECT_URI="${BETTER_AUTH_URL_VALUE}/api/auth/callback/google"
# ── Stage 1: OAuth consent screen ──────────────────────────────────────────
stage "Google Cloud: OAuth consent screen"
say "Google won't let you create an OAuth client until a consent screen exists"
say "for the project. Skip straight through if yours already has one."
open_url "https://console.cloud.google.com/apis/credentials/consent"
step "Select or create a Google Cloud project for this app."
step "User type: 'External' (or 'Internal' if you're on a Workspace org and"
step " only your org's users will ever sign in)."
step "Fill in App name, User support email, and Developer contact email,"
step " then click through Save on the remaining screens (scopes, test users)."
pause "Done configuring the consent screen?"
# ── Stage 2: OAuth client ID ────────────────────────────────────────────────
stage "Google Cloud: create OAuth client ID"
open_url "https://console.cloud.google.com/apis/credentials"
step "Click '+ Create Credentials' → 'OAuth client ID'."
step "Application type: 'Web application'."
say "Authorized redirect URI to add:"
note " $REDIRECT_URI"
warn "If BETTER_AUTH_URL changes later (e.g. you deploy this), add that"
warn "domain's /api/auth/callback/google URI here too."
step "Paste the redirect URI above into 'Authorized redirect URIs', then click 'Create'."
step "A dialog shows the Client ID and Client secret — copy both."
ask GOOGLE_CLIENT_ID "Paste the Client ID:"
ask_secret GOOGLE_CLIENT_SECRET "Paste the Client secret:"
write_env GOOGLE_CLIENT_ID "$GOOGLE_CLIENT_ID"
write_env GOOGLE_CLIENT_SECRET "$GOOGLE_CLIENT_SECRET"
# ──────────────────────────────────────────────────────────────────────────
finish
note "Restart 'npm run dev' so the server picks up the new env vars."
note "Apple SSO needs a real HTTPS domain (no localhost) — re-run /wizard for"
note "that once you have one to test against."
+31
View File
@@ -0,0 +1,31 @@
@use '../../styles/variables' as *;
.page {
max-width: 420px;
margin: 0 auto;
padding: $space-2 $space-3;
}
.card {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.heading {
margin: 0;
}
.footer {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
a {
color: $color-primary;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import styles from './AuthCard.module.scss'
type AuthCardProps = {
title: string
children: ReactNode
footer: ReactNode
}
export const AuthCard = ({ title, children, footer }: AuthCardProps) => (
<main className={styles.page}>
<div className={styles.card}>
<h1 className={styles.heading}>{title}</h1>
{children}
<p className={styles.footer}>{footer}</p>
</div>
</main>
)
@@ -0,0 +1,47 @@
@use '../../styles/variables' as *;
.buttons {
display: flex;
flex-direction: column;
gap: $space-2;
}
.google,
.apple {
display: flex;
align-items: center;
justify-content: center;
gap: $space-2;
padding: $space-2 $space-3;
border-radius: $radius;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
}
.google {
background: $color-background;
color: #3c4043;
border: 1px solid $color-border;
&:hover {
background: $color-surface;
}
}
.apple {
background: #000;
color: #fff;
border: 1px solid #000;
&:hover {
background: #1a1a1a;
}
}
.icon {
height: 18px;
width: 18px;
flex-shrink: 0;
}
+75
View File
@@ -0,0 +1,75 @@
import { authClient } from '#/lib/auth-client.ts'
import styles from './SsoButtons.module.scss'
type Provider = 'google' | 'apple'
type SsoButtonsProps = {
onError: (message: string) => void
}
const continueWith = async (
provider: Provider,
onError: (message: string) => void,
) => {
const { error } = await authClient.signIn.social({
provider,
callbackURL: '/',
})
if (error) {
onError(error.message ?? `Could not continue with ${provider}.`)
}
}
const GoogleIcon = () => (
<svg className={styles.icon} viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#4285F4"
d="M17.64 9.2045c0-.6381-.0573-1.2518-.1636-1.8409H9v3.4814h4.8436c-.2086 1.125-.8427 2.0782-1.7959 2.7164v2.2581h2.9087c1.7018-1.5668 2.6836-3.874 2.6836-6.615z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.4673-.806 5.9564-2.1805l-2.9087-2.2581c-.8059.5404-1.8368.8595-3.0477.8595-2.3441 0-4.3273-1.5831-5.0345-3.7104H.9573v2.3318C2.4382 15.9832 5.4818 18 9 18z"
/>
<path
fill="#FBBC05"
d="M3.9655 10.71c-.18-.5404-.2823-1.1177-.2823-1.71s.1023-1.1696.2823-1.71V4.9582H.9573A8.9965 8.9965 0 0 0 0 9c0 1.4523.3477 2.8268.9573 4.0418L3.9655 10.71z"
/>
<path
fill="#EA4335"
d="M9 3.5795c1.3214 0 2.5077.4541 3.4405 1.346l2.5813-2.5814C13.4632.8918 11.426 0 9 0 5.4818 0 2.4382 2.0168.9573 4.9582L3.9655 7.29C4.6727 5.1627 6.6559 3.5795 9 3.5795z"
/>
</svg>
)
const AppleIcon = () => (
<svg
className={styles.icon}
viewBox="0 0 384 512"
aria-hidden="true"
fill="currentColor"
>
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
)
export const SsoButtons = ({ onError }: SsoButtonsProps) => (
<div className={styles.buttons}>
<button
type="button"
className={styles.google}
onClick={() => continueWith('google', onError)}
>
<GoogleIcon />
Continue with Google
</button>
<button
type="button"
className={styles.apple}
onClick={() => continueWith('apple', onError)}
>
<AppleIcon />
Continue with Apple
</button>
</div>
)
@@ -0,0 +1,42 @@
@use '../../styles/variables' as *;
.trigger {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
width: 2.25rem;
border: none;
border-radius: 50%;
background: $color-background;
color: $color-text-muted;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
svg {
height: 1.25rem;
width: 1.25rem;
}
}
.badge {
position: absolute;
top: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
padding: 0 3px;
border-radius: 999px;
background: $color-primary;
color: $color-primary-contrast;
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
}
+69
View File
@@ -0,0 +1,69 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useBasket } from '#/lib/basket.tsx'
import styles from './BasketLink.module.scss'
const MotionLink = motion.create(Link)
const BasketIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h2l2.4 12.2a2 2 0 0 0 2 1.8h8.4a2 2 0 0 0 2-1.6L22 9H6" />
<circle cx="9" cy="20" r="1.4" />
<circle cx="18" cy="20" r="1.4" />
</svg>
)
// Enter bounces in with a spring; exit uses a quick fixed-duration tween
// instead — a spring's slow settling tail would otherwise leave an
// invisible-but-still-interactive link sitting in the DOM for ~2s after it's
// visually gone.
const enterTransition = { type: 'spring', stiffness: 500, damping: 22 } as const
const exitTransition = { duration: 0.18, ease: 'easeIn' } as const
export const BasketLink = () => {
const { itemCount } = useBasket()
const reduceMotion = useReducedMotion()
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const onBasketPage = pathname === '/basket'
const pop = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1, transition: exitTransition },
exit: { opacity: 0, transition: exitTransition },
}
: {
initial: { opacity: 0, scale: 0.4 },
animate: { opacity: 1, scale: 1, transition: enterTransition },
exit: { opacity: 0, scale: 0.4, transition: exitTransition },
}
return (
<AnimatePresence>
{itemCount > 0 && !onBasketPage && (
<MotionLink
key="basket-link"
to="/basket"
className={styles.trigger}
aria-label={`Basket, ${itemCount} item${itemCount === 1 ? '' : 's'}`}
{...pop}
>
<BasketIcon />
<span className={styles.badge}>{itemCount}</span>
</MotionLink>
)}
</AnimatePresence>
)
}
@@ -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>
)
}
+147 -10
View File
@@ -6,6 +6,31 @@
padding: $space-5 $space-3;
}
.backLink {
display: inline-flex;
align-items: center;
gap: $space-1;
margin: 0 0 $space-3;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: none;
&:hover {
color: $color-primary;
.backArrow {
transform: translateX(-3px);
}
}
}
.backArrow {
height: 1rem;
width: 1rem;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.heading {
margin: 0 0 $space-1;
}
@@ -48,22 +73,117 @@
color: $color-text-muted;
}
.steps {
.timeline {
margin: 0 0 $space-2;
padding-left: $space-4;
font-size: 0.85rem;
color: $color-text-muted;
padding: 0;
list-style: none;
}
.step {
&[data-status='done'] {
text-decoration: line-through;
position: relative;
display: grid;
grid-template-columns: $space-3 1fr;
column-gap: $space-2;
padding-bottom: $space-3;
font-size: 0.85rem;
color: $color-text-muted;
&:last-child {
padding-bottom: 0;
}
}
.stepMarker {
position: relative;
display: flex;
justify-content: center;
}
.stepDot {
position: relative;
width: 10px;
height: 10px;
margin-top: 3px;
border: 2px solid $color-border;
border-radius: 50%;
background: $color-background;
z-index: 1;
}
.stepMarker::after {
content: '';
position: absolute;
top: 15px;
bottom: -$space-3;
left: 50%;
width: 2px;
background: $color-border;
transform: translateX(-50%);
}
.step:last-child .stepMarker::after {
display: none;
}
.stepContent {
display: flex;
flex-direction: column;
gap: $space-1;
padding-top: 1px;
}
.stepMeta {
font-size: 0.75rem;
}
.step[data-status='done'] {
.stepDot {
border-color: $color-primary;
background: $color-primary;
}
&[data-status='in_progress'] {
color: $color-primary;
font-weight: 600;
.stepMarker::after {
background: $color-primary;
}
.stepLabel {
color: $color-text;
text-decoration: line-through;
}
}
.step[data-status='in_progress'] {
.stepDot {
border-color: $color-success;
background: $color-success;
@media (prefers-reduced-motion: no-preference) {
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: $color-success;
animation: sonarPing 1.6s ease-out infinite;
}
}
}
}
@keyframes sonarPing {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(2.8);
opacity: 0;
}
}
.step[data-status='pending'] {
opacity: 0.6;
}
.eta {
@@ -75,12 +195,16 @@
.actions {
display: flex;
gap: $space-2;
align-items: center;
button {
button,
a {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-text;
text-decoration: none;
cursor: pointer;
&:disabled {
@@ -88,4 +212,17 @@
cursor: not-allowed;
}
}
.cancelButton {
padding: $space-1 $space-2;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: underline;
&:hover:not(:disabled) {
color: $color-danger;
}
}
}
+69 -19
View File
@@ -1,12 +1,37 @@
import { useRouter } from '@tanstack/react-router'
import { Link, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { formatEtaMessage } from '#/lib/format.ts'
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss'
const SUPPORT_EMAIL = 'help@ghostkitchen.example'
const helpMailto = (orderId: number, item: OrderItemStatusView) => {
const subject = `Help with Order #${orderId} — ${item.menuItemName}`
const body = `Order #${orderId}\nItem: ${item.menuItemName}\nStatus: ${item.status}\n\nWhat's going on?\n`
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
}
const BackArrowIcon = () => (
<svg
className={styles.backArrow}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 12H5" />
<path d="M11 18l-6-6 6-6" />
</svg>
)
export type OrderStatusResult = {
id: number
status: OrderStatus
@@ -41,8 +66,14 @@ export const OrderStatusView = ({
return (
<section className={styles.page}>
<Link to="/orders" className={styles.backLink}>
<BackArrowIcon />
Back to orders
</Link>
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
<h1 className={styles.heading}>Order #{status.id}</h1>
<p className={styles.orderStatus}>Status: {status.status}</p>
</div>
{error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}>
{status.items.map((item) => (
@@ -51,41 +82,43 @@ export const OrderStatusView = ({
<span className={styles.itemName}>{item.menuItemName}</span>
<span className={styles.itemStatus}>{item.status}</span>
</div>
<ol className={styles.steps}>
<ol className={styles.timeline}>
{item.steps.map((step) => (
<li
key={step.sequence}
className={styles.step}
data-status={step.status}
aria-current={step.status === 'in_progress' ? 'step' : undefined}
>
{step.machineTag}
{step.status === 'queued' &&
step.queuePosition !== null &&
` — #${step.queuePosition} in line`}
<span className={styles.stepMarker} aria-hidden="true">
<span className={styles.stepDot} />
</span>
<span className={styles.stepContent}>
<span className={styles.stepLabel}>{step.name}</span>
{step.status === 'queued' && step.queuePosition !== null && (
<span className={styles.stepMeta}>
#{step.queuePosition} in line
</span>
)}
</span>
</li>
))}
</ol>
{item.etaSeconds !== null && (
<p className={styles.eta}>
Ready in about {Math.ceil(item.etaSeconds / 60)} min
</p>
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
)}
<div className={styles.actions}>
{item.cancellable && (
<button
type="button"
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
cancelOrderItem({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
onClick={() => {
//
}}
>
Cancel
Buy again
</button>
)}
{item.status === 'ready' && (
<button
type="button"
@@ -101,6 +134,23 @@ export const OrderStatusView = ({
I picked this up
</button>
)}
<a href={helpMailto(status.id, item)}>Get help</a>
{item.cancellable && (
<button
type="button"
className={styles.cancelButton}
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
cancelOrderItem({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
Cancel
</button>
)}
</div>
</li>
))}
@@ -0,0 +1,67 @@
@use '../../styles/variables' as *;
.page {
max-width: 720px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.empty {
color: $color-text-muted;
a {
color: $color-primary;
}
}
.orders {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.order {
display: block;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
color: inherit;
text-decoration: none;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
}
.orderHeader {
display: flex;
justify-content: space-between;
font-weight: 600;
margin-bottom: $space-1;
}
.orderStatus {
text-transform: capitalize;
color: $color-text-muted;
font-weight: 500;
}
.orderItems {
margin: 0 0 $space-1;
font-size: 0.9rem;
}
.orderMeta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
}
+61
View File
@@ -0,0 +1,61 @@
import { Link } from '@tanstack/react-router'
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import styles from './OrdersListView.module.scss'
type OrdersListViewProps = {
orders: Array<CustomerOrderSummary>
}
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatItems = (items: CustomerOrderSummary['items']) =>
items
.map(({ menuItemName, quantity }) =>
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
)
.join(', ')
export const OrdersListView = ({ orders }: OrdersListViewProps) => (
<section className={styles.page}>
<h1 className={styles.heading}>Your orders</h1>
{orders.length === 0 ? (
<p className={styles.empty}>
You haven't placed an order yet — <Link to="/">browse the menu</Link>{' '}
to get started.
</p>
) : (
<ul className={styles.orders}>
{orders.map((order) => (
<li key={order.id}>
<Link
to="/orders/$orderId"
params={{ orderId: String(order.id) }}
search={
order.guestAccessToken
? { token: order.guestAccessToken }
: undefined
}
className={styles.order}
style={{ viewTransitionName: `order-card-${order.id}` }}
>
<div className={styles.orderHeader}>
<span className={styles.orderName}>Order #{order.id}</span>
<span className={styles.orderStatus}>{order.status}</span>
</div>
<p className={styles.orderItems}>{formatItems(order.items)}</p>
<p className={styles.orderMeta}>
{order.createdAt.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})}{' '}
· {formatPrice(order.totalCents)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
)
+17
View File
@@ -0,0 +1,17 @@
.logo {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.01em;
}
.faded {
font-weight: 500;
opacity: 0.45;
}
.ghostIcon {
display: inline-block;
height: 0.78em;
width: 0.78em;
vertical-align: -0.05em;
}
+60
View File
@@ -0,0 +1,60 @@
import { useId } from 'react'
import styles from './Logo.module.scss'
type LogoProps = {
name: string
}
const GhostIcon = () => {
const eyeMaskId = useId()
return (
<svg
className={styles.ghostIcon}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<mask id={eyeMaskId}>
<rect x="0" y="0" width="24" height="24" fill="#fff" />
<circle cx="9.5" cy="10.5" r="1.3" />
<circle cx="14.5" cy="10.5" r="1.3" />
</mask>
<path
d="M12 2C7.58 2 4 5.58 4 10v7l2.5 2.2L9 17l3 2.2 3-2.2 2.5 2.2L20 17v-7c0-4.42-3.58-8-8-8Z"
mask={`url(#${eyeMaskId})`}
/>
</svg>
)
}
// The brand's first word fades toward transparent — a literal "ghost" for a
// Kitchen whose name starts with one. When it actually is "Ghost", its "o"
// is swapped for a small ghost mark instead (a differently-named deployment
// just gets the fade).
export const Logo = ({ name }: LogoProps) => {
const [first, ...rest] = name.split(' ')
const restText = rest.join(' ')
if (!restText) return <span className={styles.logo}>{name}</span>
const oIndex = first.toLowerCase().indexOf('o')
return (
<span className={styles.logo}>
<span className={styles.faded}>
{oIndex === -1 ? (
first
) : (
<>
{first.slice(0, oIndex)}
<GhostIcon />
{first.slice(oIndex + 1)}
</>
)}
</span>{' '}
{restText}
</span>
)
}
+5
View File
@@ -55,6 +55,10 @@ export const menuItemSteps = pgTable('menu_item_steps', {
.notNull()
.references(() => menuItems.id),
sequence: integer().notNull(),
// Customer-facing label, e.g. "Flattening dough" — distinct from the
// Machine Tag because several Steps in a Recipe can share one Tag (and
// so the same physical Machine) while doing different work on it.
name: text().notNull(),
machineTagId: integer('machine_tag_id')
.notNull()
.references(() => machineTags.id),
@@ -112,6 +116,7 @@ export const orderItemSteps = pgTable('order_item_steps', {
.notNull()
.references(() => orderItems.id),
sequence: integer().notNull(),
name: text().notNull(),
machineTagId: integer('machine_tag_id')
.notNull()
.references(() => machineTags.id),
+57 -4
View File
@@ -11,6 +11,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'Ghost Kitchen' })
.returning()
const [prepTag] = await db
.insert(schema.machineTags)
.values({ name: 'prep' })
.returning()
const [ovenTag] = await db
.insert(schema.machineTags)
.values({ name: 'oven' })
@@ -24,6 +28,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'assembly' })
.returning()
const [prep1] = await db
.insert(schema.machines)
.values({ name: 'Prep Station 1', machineTagId: prepTag.id })
.returning()
const [oven1] = await db
.insert(schema.machines)
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
@@ -47,12 +55,50 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
})
.returning()
await db.insert(schema.menuItemSteps).values({
await db.insert(schema.menuItemSteps).values([
{
menuItemId: pizza.id,
sequence: 1,
name: 'Flattening dough',
machineTagId: prepTag.id,
estimatedDurationSeconds: 30,
},
{
menuItemId: pizza.id,
sequence: 2,
name: 'Spreading sauce',
machineTagId: prepTag.id,
estimatedDurationSeconds: 20,
},
{
menuItemId: pizza.id,
sequence: 3,
name: 'Adding toppings',
machineTagId: prepTag.id,
estimatedDurationSeconds: 30,
},
{
menuItemId: pizza.id,
sequence: 4,
name: 'Baking in the oven',
machineTagId: ovenTag.id,
estimatedDurationSeconds: 600,
})
},
{
menuItemId: pizza.id,
sequence: 5,
name: 'Slicing',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 20,
},
{
menuItemId: pizza.id,
sequence: 6,
name: 'Packaging',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 20,
},
])
const [burger] = await db
.insert(schema.menuItems)
@@ -68,12 +114,14 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
{
menuItemId: burger.id,
sequence: 1,
name: 'Grilling patty',
machineTagId: grillTag.id,
estimatedDurationSeconds: 240,
},
{
menuItemId: burger.id,
sequence: 2,
name: 'Assembling burger',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 90,
},
@@ -81,8 +129,13 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
return {
brand,
machineTags: { oven: ovenTag, grill: grillTag, assembly: assemblyTag },
machines: { oven1, grill1, assembly1 },
machineTags: {
prep: prepTag,
oven: ovenTag,
grill: grillTag,
assembly: assemblyTag,
},
machines: { prep1, oven1, grill1, assembly1 },
menuItems: { pizza, burger },
}
}
+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(),
)
}
@@ -1,44 +1,136 @@
@use '../../styles/variables' as *;
.avatar {
height: 2rem;
width: 2rem;
background: $color-surface;
.wrapper {
position: relative;
}
.avatarPulse {
animation: pulse 1.5s ease-in-out infinite;
}
.avatarInitial {
.trigger {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
color: $color-text-muted;
}
.session {
display: flex;
align-items: center;
gap: $space-2;
}
.signOut {
flex: 1;
height: 2.25rem;
padding: 0 $space-3;
font-size: 0.875rem;
font-weight: 500;
background: $color-background;
color: $color-text;
width: 2.25rem;
padding: 0;
border: 1px solid $color-border;
border-radius: 50%;
background: $color-background;
color: $color-text-muted;
cursor: pointer;
overflow: hidden;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
svg {
height: 1.25rem;
width: 1.25rem;
}
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
.avatarImage {
height: 100%;
width: 100%;
object-fit: cover;
}
.avatarInitial {
font-size: 0.85rem;
font-weight: 600;
color: $color-text;
}
.menu {
position: absolute;
top: calc(100% + #{$space-2});
right: 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 11rem;
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
z-index: 10;
}
.menuLabel {
padding: $space-2;
border-bottom: 1px solid $color-border;
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: $color-text;
}
.menuButton {
display: flex;
align-items: center;
gap: $space-2;
width: 100%;
padding: $space-2;
border: none;
border-radius: calc($radius - 2px);
background: none;
color: $color-text;
font-size: 0.875rem;
font-weight: 500;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
}
.loginButton {
display: block;
width: 100%;
margin: $space-1 0;
padding: $space-2;
border: none;
border-radius: calc($radius - 2px);
background: $color-primary;
color: $color-primary-contrast;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.signupLink {
display: block;
width: 100%;
padding: $space-1 $space-2;
color: $color-text-muted;
font-size: 0.8rem;
text-align: center;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.menuIcon {
height: 1.1rem;
width: 1.1rem;
flex-shrink: 0;
}
@keyframes pulse {
+128 -14
View File
@@ -1,37 +1,151 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { authClient } from '#/lib/auth-client'
import styles from './header-user.module.scss'
const AccountIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<circle cx="12" cy="8" r="4" strokeWidth="1.5" />
<path d="M4 20c0-4 3.5-7 8-7s8 3 8 7" strokeWidth="1.5" />
</svg>
)
const OrdersIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 3h6l1 3H8l1-3Z" />
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
<path d="M9 11h6" />
</svg>
)
const LogOutIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
)
const BetterAuthHeader = () => {
const { data: session, isPending } = authClient.useSession()
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
useEffect(() => {
setOpen(false)
}, [pathname])
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
if (isPending) {
return <div className={`${styles.avatar} ${styles.avatarPulse}`} />
return <div className={`${styles.trigger} ${styles.pulse}`} />
}
if (session?.user) {
return (
<div className={styles.session}>
{session.user.image ? (
<img src={session.user.image} alt="" className={styles.avatar} />
) : (
<div className={`${styles.avatar} ${styles.avatarInitial}`}>
<span>{session.user.name.charAt(0).toUpperCase() || 'U'}</span>
</div>
)}
<div className={styles.wrapper} ref={wrapperRef}>
<button
type="button"
className={styles.trigger}
onClick={() => setOpen((value) => !value)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={
session?.user ? `Account menu for ${session.user.name}` : 'Account'
}
>
{session?.user ? (
session.user.image ? (
<img src={session.user.image} alt="" className={styles.avatarImage} />
) : (
<span className={styles.avatarInitial}>
{session.user.name.charAt(0).toUpperCase() || 'U'}
</span>
)
) : (
<AccountIcon />
)}
</button>
{open && (
<div className={styles.menu} role="menu">
{session?.user ? (
<>
<p className={styles.menuLabel}>{session.user.name}</p>
<Link to="/orders" role="menuitem" className={styles.menuButton}>
<OrdersIcon />
Orders
</Link>
<button
type="button"
role="menuitem"
className={styles.menuButton}
onClick={() => {
setOpen(false)
void authClient.signOut()
}}
className={styles.signOut}
>
<LogOutIcon />
Sign out
</button>
</>
) : (
<>
<p className={styles.menuLabel}>Guest</p>
<Link to="/orders" role="menuitem" className={styles.menuButton}>
<OrdersIcon />
Orders
</Link>
<Link to="/login" role="menuitem" className={styles.loginButton}>
Sign in
</Link>
<Link to="/signup" role="menuitem" className={styles.signupLink}>
New here? Sign up
</Link>
</>
)}
</div>
)}
</div>
)
}
return null
}
export default BetterAuthHeader
+20
View File
@@ -2,10 +2,30 @@ import { betterAuth } from 'better-auth'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { Pool } from 'pg'
// Each provider is only registered once its credentials are configured, so
// local dev without OAuth apps set up still works via email/password.
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: {
enabled: true,
},
socialProviders: {
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET
? {
apple: {
clientId: process.env.APPLE_CLIENT_ID,
clientSecret: process.env.APPLE_CLIENT_SECRET,
},
}
: {}),
},
plugins: [tanstackStartCookies()],
})
+90
View File
@@ -0,0 +1,90 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import type { ReactNode } from 'react'
const STORAGE_KEY = 'ghost-kitchen-basket'
type BasketContextValue = {
quantities: Record<number, number>
itemCount: number
add: (menuItemId: number) => void
remove: (menuItemId: number) => void
clear: () => void
}
const BasketContext = createContext<BasketContextValue | null>(null)
const readStoredQuantities = (): Record<number, number> => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<number, number>) : {}
} catch {
return {}
}
}
export const BasketProvider = ({ children }: { children: ReactNode }) => {
const [quantities, setQuantities] = useState<Record<number, number>>({})
// Read from localStorage 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.
useEffect(() => {
setQuantities(readStoredQuantities())
}, [])
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quantities))
} catch {
// Unavailable (private mode, quota) — basket still works for this
// page load, it just won't persist across a reload.
}
}, [quantities])
const add = (menuItemId: number) =>
setQuantities((prev) => ({
...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
}))
const remove = (menuItemId: number) =>
setQuantities((prev) => {
const current = prev[menuItemId] ?? 0
if (current <= 1) {
const next = { ...prev }
delete next[menuItemId]
return next
}
return { ...prev, [menuItemId]: current - 1 }
})
const clear = () => setQuantities({})
const itemCount = useMemo(
() => Object.values(quantities).reduce((sum, quantity) => sum + quantity, 0),
[quantities],
)
const value = useMemo(
() => ({ quantities, itemCount, add, remove, clear }),
[quantities, itemCount],
)
return (
<BasketContext.Provider value={value}>{children}</BasketContext.Provider>
)
}
export const useBasket = () => {
const context = useContext(BasketContext)
if (!context) {
throw new Error('useBasket must be used within a BasketProvider')
}
return context
}
+31
View File
@@ -0,0 +1,31 @@
export const formatDuration = (totalSeconds: number) => {
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60))
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) return `${minutes} min`
if (minutes === 0) return `${hours} hr`
return `${hours} hr ${minutes} min`
}
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate())
export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
const target = new Date(now.getTime() + etaSeconds * 1000)
const time = target.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const dayDiff = Math.round(
(startOfDay(target).getTime() - startOfDay(now).getTime()) / 86_400_000,
)
if (dayDiff === 0) return `today, ${time}`
if (dayDiff === 1) return `tomorrow, ${time}`
return `${target.toLocaleDateString([], { weekday: 'long' })}, ${time}`
}
const ABSOLUTE_TIME_THRESHOLD_SECONDS = 3 * 60 * 60
export const formatEtaMessage = (etaSeconds: number, now = new Date()) =>
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
? `Ready ${formatEtaClock(etaSeconds, now)}`
: `Ready in about ${formatDuration(etaSeconds)}`
+39
View File
@@ -0,0 +1,39 @@
// A guest's name/contact, remembered locally so checkout doesn't ask again
// on their next order from this browser.
const STORAGE_KEY = 'ghost-kitchen-guest-identity'
export type GuestIdentity = { name: string; contact: string }
const isGuestIdentity = (value: unknown): value is GuestIdentity =>
typeof value === 'object' &&
value !== null &&
typeof (value as GuestIdentity).name === 'string' &&
typeof (value as GuestIdentity).contact === 'string'
export const getGuestIdentity = (): GuestIdentity | null => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
return isGuestIdentity(parsed) ? parsed : null
} catch {
return null
}
}
export const saveGuestIdentity = (identity: GuestIdentity) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identity))
} catch {
// Unavailable (private mode, quota) — checkout still works, it just
// asks again next time.
}
}
export const clearGuestIdentity = () => {
try {
window.localStorage.removeItem(STORAGE_KEY)
} catch {
// Unavailable — nothing to clear.
}
}
+38
View File
@@ -0,0 +1,38 @@
// Guests have no account to list Orders under, so their Order history is
// tracked client-side instead: each placed guest Order's id + access token
// is remembered here so `/orders` can look them back up without a sign-in.
const STORAGE_KEY = 'ghost-kitchen-guest-orders'
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
const isGuestOrderRef = (value: unknown): value is GuestOrderRef =>
typeof value === 'object' &&
value !== null &&
typeof (value as GuestOrderRef).orderId === 'number' &&
typeof (value as GuestOrderRef).guestAccessToken === 'string'
export const getGuestOrderRefs = (): Array<GuestOrderRef> => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter(isGuestOrderRef) : []
} catch {
return []
}
}
export const addGuestOrderRef = (ref: GuestOrderRef) => {
try {
const existing = getGuestOrderRefs().filter(
(stored) => stored.orderId !== ref.orderId,
)
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify([ref, ...existing]),
)
} catch {
// Unavailable (private mode, quota) — the order still works via its
// own link, it just won't show up in the local Orders list.
}
}
+97 -3
View File
@@ -10,6 +10,10 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as BasketRouteImport } from './routes/basket'
import { Route as LoginRouteImport } from './routes/login'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -18,6 +22,26 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const BasketRoute = BasketRouteImport.update({
id: '/basket',
path: '/basket',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const SignupRoute = SignupRouteImport.update({
id: '/signup',
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersIndexRoute = OrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
id: '/orders/$orderId',
path: '/orders/$orderId',
@@ -31,31 +55,69 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/orders/$orderId' | '/api/auth/$'
fullPaths:
| '/'
| '/basket'
| '/login'
| '/signup'
| '/orders/$orderId'
| '/orders/'
| '/api/auth/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/orders/$orderId' | '/api/auth/$'
id: '__root__' | '/' | '/orders/$orderId' | '/api/auth/$'
to:
| '/'
| '/basket'
| '/login'
| '/signup'
| '/orders/$orderId'
| '/orders'
| '/api/auth/$'
id:
| '__root__'
| '/'
| '/basket'
| '/login'
| '/signup'
| '/orders/$orderId'
| '/orders/'
| '/api/auth/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BasketRoute: typeof BasketRoute
LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
OrdersIndexRoute: typeof OrdersIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
}
@@ -68,6 +130,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/basket': {
id: '/basket'
path: '/basket'
fullPath: '/basket'
preLoaderRoute: typeof BasketRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/signup': {
id: '/signup'
path: '/signup'
fullPath: '/signup'
preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/': {
id: '/orders/'
path: '/orders'
fullPath: '/orders/'
preLoaderRoute: typeof OrdersIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/$orderId': {
id: '/orders/$orderId'
path: '/orders/$orderId'
@@ -87,7 +177,11 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BasketRoute: BasketRoute,
LoginRoute: LoginRoute,
SignupRoute: SignupRoute,
OrdersOrderIdRoute: OrdersOrderIdRoute,
OrdersIndexRoute: OrdersIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport
+1
View File
@@ -7,6 +7,7 @@ export const getRouter = () => {
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultViewTransition: true,
})
return router
+30
View File
@@ -0,0 +1,30 @@
@use '../styles/variables' as *;
.topBar {
position: relative;
// Explicit stacking context, ranked above any page content: setting
// `view-transition-name` on an element (here and on OrderStatusView's
// order card) implicitly makes it its own stacking context too — without
// this, whichever one comes later in the DOM wins ties on z-index:0, so
// the account menu dropdown was rendering underneath the order card on
// the order status page.
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-3;
border-bottom: 1px solid $color-border;
}
.brand {
font-size: 1.1rem;
font-weight: 700;
color: $color-text;
text-decoration: none;
}
.actions {
display: flex;
align-items: center;
gap: $space-2;
}
+47 -4
View File
@@ -1,16 +1,45 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import {
HeadContent,
Link,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import '../styles/globals.scss'
import { BasketLink } from '#/components/basket/BasketLink.tsx'
import { Logo } from '#/components/ui/Logo.tsx'
import BetterAuthHeader from '#/integrations/better-auth/header-user.tsx'
import { BasketProvider } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
const RootDocument = ({ children }: { children: React.ReactNode }) => (
import '../styles/globals.scss'
import styles from './__root.module.scss'
const RootDocument = ({ children }: { children: React.ReactNode }) => {
const { brand } = Route.useLoaderData()
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<BasketProvider>
<div
className={styles.topBar}
style={{ viewTransitionName: 'site-header' }}
>
<Link to="/" className={styles.brand}>
<Logo name={brand.name} />
</Link>
<div className={styles.actions}>
<BasketLink />
<BetterAuthHeader />
</div>
</div>
{children}
</BasketProvider>
<TanStackDevtools
config={{
position: 'bottom-right',
@@ -25,9 +54,11 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => (
<Scripts />
</body>
</html>
)
)
}
export const Route = createRootRoute({
loader: async () => ({ brand: await getBrand() }),
head: () => ({
meta: [
{
@@ -41,6 +72,18 @@ export const Route = createRootRoute({
title: 'Ghost Kitchen',
},
],
links: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap',
},
],
}),
shellComponent: RootDocument,
})
+87
View File
@@ -0,0 +1,87 @@
@use '../styles/variables' as *;
.page {
max-width: 640px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.empty {
color: $color-text-muted;
a {
color: $color-primary;
}
}
.lines {
list-style: none;
margin: 0 0 $space-3;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-2;
}
.line {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.lineInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.lineName {
font-weight: 600;
}
.linePrice {
color: $color-text-muted;
font-size: 0.9rem;
}
.quantity {
display: flex;
align-items: center;
gap: $space-2;
}
.stepperButton {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-background;
cursor: pointer;
align-self: center;
justify-self: center;
}
.count {
min-width: 1.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.total {
display: flex;
justify-content: space-between;
font-weight: 600;
padding: $space-3 0;
margin-bottom: $space-3;
border-top: 1px solid $color-border;
border-bottom: 1px solid $color-border;
}
+103
View File
@@ -0,0 +1,103 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { useBasket } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
import styles from './basket.module.scss'
const BasketPage = () => {
const { menu } = Route.useLoaderData()
const { quantities, add, remove, clear } = useBasket()
const navigate = useNavigate()
const lines = menu
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
.filter(({ quantity }) => quantity > 0)
const totalCents = lines.reduce(
(sum, { item, quantity }) => sum + item.priceCents * quantity,
0,
)
const items = lines.flatMap(({ item, quantity }) =>
Array.from({ length: quantity }, () => ({ menuItemId: item.id })),
)
return (
<main className={styles.page}>
<h1 className={styles.heading}>Your basket</h1>
{lines.length === 0 ? (
<p className={styles.empty}>
Your basket is empty — <Link to="/">browse the menu</Link> to add
something.
</p>
) : (
<>
<ul className={styles.lines}>
{lines.map(({ item, quantity }) => (
<li key={item.id} className={styles.line}>
<div className={styles.lineInfo}>
<span className={styles.lineName}>{item.name}</span>
<span className={styles.linePrice}>
${((item.priceCents * quantity) / 100).toFixed(2)}
</span>
</div>
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => remove(item.id)}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<span className={styles.count}>{quantity}</span>
<button
type="button"
className={styles.stepperButton}
onClick={() => add(item.id)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</li>
))}
</ul>
<div className={styles.total}>
<span>Total</span>
<span>${(totalCents / 100).toFixed(2)}</span>
</div>
<CheckoutSection
items={items}
disabled={items.length === 0}
onPlaced={async (order) => {
await navigate({
to: '/orders/$orderId',
params: { orderId: String(order.id) },
search: order.guestAccessToken
? { token: order.guestAccessToken }
: {},
})
clear()
}}
/>
</>
)}
</main>
)
}
export const Route = createFileRoute('/basket')({
loader: async () => {
const brand = await getBrand()
const menu = await getMenu({ data: { brandId: brand.id } })
return { menu }
},
component: BasketPage,
})
+1 -26
View File
@@ -1,32 +1,7 @@
@use '../styles/variables' as *;
.page {
max-width: 960px;
max-width: 640px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.layout {
display: grid;
grid-template-columns: 1fr;
gap: $space-4;
@media (min-width: $breakpoint-tablet) {
grid-template-columns: 2fr 1fr;
}
}
.sidebar {
display: flex;
flex-direction: column;
gap: $space-3;
align-self: start;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
+5 -51
View File
@@ -1,69 +1,24 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { MenuList } from '#/components/ordering/MenuList.tsx'
import { OrderSummary } from '#/components/ordering/OrderSummary.tsx'
import { useBasket } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
import styles from './index.module.scss'
const OrderingPage = () => {
const { brand, menu } = Route.useLoaderData()
const navigate = useNavigate()
const [quantities, setQuantities] = useState<Record<number, number>>({})
const add = (menuItemId: number) =>
setQuantities((prev) => ({
...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
}))
const remove = (menuItemId: number) =>
setQuantities((prev) => {
const current = prev[menuItemId] ?? 0
if (current <= 1) {
const next = { ...prev }
delete next[menuItemId]
return next
}
return { ...prev, [menuItemId]: current - 1 }
})
const items = Object.entries(quantities).flatMap(([menuItemId, quantity]) =>
Array.from({ length: quantity }, () => ({
menuItemId: Number(menuItemId),
})),
)
const { menu } = Route.useLoaderData()
const { quantities, add, remove } = useBasket()
return (
<main className={styles.page}>
<h1 className={styles.heading}>{brand.name}</h1>
<div className={styles.layout}>
<MenuList
menu={menu}
quantities={quantities}
onAdd={add}
onRemove={remove}
/>
<aside className={styles.sidebar}>
<OrderSummary menu={menu} quantities={quantities} />
<CheckoutSection
items={items}
disabled={items.length === 0}
onPlaced={(order) =>
navigate({
to: '/orders/$orderId',
params: { orderId: String(order.id) },
search: order.guestAccessToken
? { token: order.guestAccessToken }
: {},
})
}
/>
</aside>
</div>
</main>
)
}
@@ -71,8 +26,7 @@ const OrderingPage = () => {
export const Route = createFileRoute('/')({
loader: async () => {
const brand = await getBrand()
const menu = await getMenu({ data: { brandId: brand.id } })
return { brand, menu }
return { menu: await getMenu({ data: { brandId: brand.id } }) }
},
component: OrderingPage,
})
+50
View File
@@ -0,0 +1,50 @@
@use '../styles/variables' as *;
.form {
display: flex;
flex-direction: column;
gap: $space-2;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
}
}
.submitButton {
padding: $space-2 $space-3;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
.divider {
text-align: center;
font-size: 0.85rem;
color: $color-text-muted;
}
+81
View File
@@ -0,0 +1,81 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { AuthCard } from '#/components/auth/AuthCard.tsx'
import { SsoButtons } from '#/components/auth/SsoButtons.tsx'
import { authClient } from '#/lib/auth-client.ts'
import styles from './login.module.scss'
const LoginPage = () => {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (event: FormEvent) => {
event.preventDefault()
setSubmitting(true)
setError(null)
const { error: signInError } = await authClient.signIn.email({
email,
password,
})
setSubmitting(false)
if (signInError) {
setError(signInError.message ?? 'Could not sign in. Please try again.')
return
}
void navigate({ to: '/' })
}
return (
<AuthCard
title="Sign in"
footer={
<>
Don&apos;t have an account? <Link to="/signup">Sign up</Link>
</>
}
>
<form className={styles.form} onSubmit={submit}>
<label className={styles.field}>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={submitting}
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
{error && <p className={styles.error}>{error}</p>}
</form>
<div className={styles.divider}>or</div>
<SsoButtons onError={setError} />
</AuthCard>
)
}
export const Route = createFileRoute('/login')({
component: LoginPage,
})
+37
View File
@@ -0,0 +1,37 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { OrdersListView } from '#/components/orders/OrdersListView.tsx'
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { getGuestOrderRefs } from '#/lib/guestOrders.ts'
import { getGuestOrders, getMyOrders } from '#/server/orders.ts'
const OrdersRoute = () => {
const accountOrders = Route.useLoaderData()
const [guestOrders, setGuestOrders] = useState<Array<
CustomerOrderSummary
> | null>(null)
// Guest history lives in localStorage, so it can only be read client-side
// — only attempted once the loader has confirmed there's no account.
useEffect(() => {
if (accountOrders !== null) return
const refs = getGuestOrderRefs()
if (refs.length === 0) {
setGuestOrders([])
return
}
void getGuestOrders({ data: { refs } }).then(setGuestOrders)
}, [accountOrders])
const orders = accountOrders ?? guestOrders
if (orders === null) return null
return <OrdersListView orders={orders} />
}
export const Route = createFileRoute('/orders/')({
loader: () => getMyOrders().catch(() => null),
component: OrdersRoute,
})
+50
View File
@@ -0,0 +1,50 @@
@use '../styles/variables' as *;
.form {
display: flex;
flex-direction: column;
gap: $space-2;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
}
}
.submitButton {
padding: $space-2 $space-3;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
.divider {
text-align: center;
font-size: 0.85rem;
color: $color-text-muted;
}
+94
View File
@@ -0,0 +1,94 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { AuthCard } from '#/components/auth/AuthCard.tsx'
import { SsoButtons } from '#/components/auth/SsoButtons.tsx'
import { authClient } from '#/lib/auth-client.ts'
import styles from './signup.module.scss'
const SignupPage = () => {
const navigate = useNavigate()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (event: FormEvent) => {
event.preventDefault()
setSubmitting(true)
setError(null)
const { error: signUpError } = await authClient.signUp.email({
name,
email,
password,
})
setSubmitting(false)
if (signUpError) {
setError(
signUpError.message ?? 'Could not create an account. Please try again.',
)
return
}
void navigate({ to: '/' })
}
return (
<AuthCard
title="Create an account"
footer={
<>
Already have an account? <Link to="/login">Sign in</Link>
</>
}
>
<form className={styles.form} onSubmit={submit}>
<label className={styles.field}>
Name
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={submitting}
>
{submitting ? 'Creating account…' : 'Create account'}
</button>
{error && <p className={styles.error}>{error}</p>}
</form>
<div className={styles.divider}>or</div>
<SsoButtons onError={setError} />
</AuthCard>
)
}
export const Route = createFileRoute('/signup')({
component: SignupPage,
})
+22
View File
@@ -62,6 +62,28 @@ export const getOrderStatus = createServerFn({ method: 'GET' })
ordering.getOrderStatus(db, data.orderId, await resolveAccess(data)),
)
export const getMyOrders = createServerFn({ method: 'GET' }).handler(
async () => {
const customerId = await getSessionCustomerId()
if (!customerId) {
throw new Error('You must be signed in to view your orders')
}
return ordering.getOrdersForCustomer(db, customerId)
},
)
export const getGuestOrders = createServerFn({ method: 'GET' })
.validator(
z.object({
refs: z.array(
z.object({ orderId: z.number(), guestAccessToken: z.string() }),
),
}),
)
.handler(async ({ data }) =>
ordering.getGuestOrderSummaries(db, data.refs),
)
export const cancelOrderItem = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() }))
.handler(async ({ data }) =>
+77
View File
@@ -21,3 +21,80 @@ body {
button {
font: inherit;
}
// Route navigation transitions, powered by the router's defaultViewTransition
// (see router.tsx). No-ops in browsers without View Transitions support.
@media (prefers-reduced-motion: no-preference) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.22s;
animation-timing-function: ease;
}
::view-transition-old(root) {
animation-name: view-transition-fade-out;
}
::view-transition-new(root) {
animation-name: view-transition-fade-in;
}
// Any element that carries a matching `view-transition-name` on both the
// outgoing and incoming page (e.g. an Order card morphing into its detail
// header, see OrdersListView / OrderStatusView) gets its own transition
// group — this smooths and slows the browser's default morph/resize
// animation for those to match the root crossfade's pace instead of the
// UA default's snappier one.
::view-transition-group(*) {
animation-duration: 0.32s;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
// The top nav bar (see __root.tsx) carries its own `view-transition-name`
// so it's excluded from the root snapshot above — otherwise the root
// fade's translateY drags the persistent header along with it, making it
// visibly jump on every navigation. It doesn't change between routes, so
// it should just stay put: no morph, no crossfade.
::view-transition-group(site-header) {
animation: none;
}
::view-transition-old(site-header),
::view-transition-new(site-header) {
animation: none;
}
}
// document.startViewTransition() itself doesn't check this preference —
// without an explicit override every browser falls back to its own default
// crossfade/morph animations, unanimated only for the rules above (which
// only apply under no-preference). Kill all of it outright here instead.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
@keyframes view-transition-fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes view-transition-fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-6px);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { formatDuration, formatEtaClock, formatEtaMessage } from '#/lib/format.ts'
describe('formatDuration', () => {
it('rounds up to at least 1 minute', () => {
expect(formatDuration(0)).toBe('1 min')
expect(formatDuration(10)).toBe('1 min')
})
it('formats minutes under an hour', () => {
expect(formatDuration(90)).toBe('2 min')
expect(formatDuration(59 * 60)).toBe('59 min')
})
it('formats whole hours without minutes', () => {
expect(formatDuration(60 * 60)).toBe('1 hr')
expect(formatDuration(3 * 60 * 60)).toBe('3 hr')
})
it('formats hours with remaining minutes', () => {
expect(formatDuration(230 * 60)).toBe('3 hr 50 min')
})
})
describe('formatEtaClock', () => {
const noon = new Date(2026, 0, 15, 12, 0, 0)
it('labels a time later the same day as today', () => {
expect(formatEtaClock(30 * 60, noon)).toBe('today, 12:30 PM')
})
it('labels a time past midnight as tomorrow', () => {
expect(formatEtaClock(13 * 60 * 60, noon)).toBe('tomorrow, 01:00 AM')
})
it('labels a time two or more days out by weekday', () => {
expect(formatEtaClock(2 * 24 * 60 * 60, noon)).toBe('Saturday, 12:00 PM')
})
})
describe('formatEtaMessage', () => {
const noon = new Date(2026, 0, 15, 12, 0, 0)
it('shows the relative time at or under the 3 hour threshold', () => {
expect(formatEtaMessage(34 * 60, noon)).toBe('Ready in about 34 min')
expect(formatEtaMessage(3 * 60 * 60, noon)).toBe('Ready in about 3 hr')
})
it('shows the absolute time once the eta passes 3 hours', () => {
expect(formatEtaMessage(3 * 60 * 60 + 60, noon)).toBe('Ready today, 03:01 PM')
expect(formatEtaMessage(230 * 60, noon)).toBe('Ready today, 03:50 PM')
})
})
+131 -11
View File
@@ -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([])
})
})
+4
View File
@@ -7,6 +7,10 @@ import viteReact from '@vitejs/plugin-react'
const config = defineConfig({
resolve: { tsconfigPaths: true },
// Fail loudly if port 3000 is taken instead of silently picking another
// port — a silent port change breaks Better Auth's origin check, since
// BETTER_AUTH_URL is pinned to http://localhost:3000.
server: { strictPort: true },
plugins: [devtools(), tanstackStart(), viteReact()],
})