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:
@@ -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.
|
||||||
@@ -1 +1,10 @@
|
|||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
|
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=
|
||||||
|
|||||||
@@ -69,14 +69,47 @@ npm run db:migrate
|
|||||||
## Setting up Better Auth
|
## Setting up Better Auth
|
||||||
|
|
||||||
Generate and set the `BETTER_AUTH_SECRET` environment variable in your
|
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
|
```bash
|
||||||
npx @better-auth/cli secret
|
npx @better-auth/cli secret
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [Better Auth documentation](https://www.better-auth.com) for more on
|
Account creation and sign-in live at `/signup` and `/login`, supporting both
|
||||||
configuring authentication.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1788976393301,
|
"when": 1788976393301,
|
||||||
"tag": "0002_large_lake",
|
"tag": "0002_large_lake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789028563859,
|
||||||
|
"tag": "0003_soft_psynapse",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+40
-3
@@ -13,6 +13,7 @@
|
|||||||
"better-auth": "^1.5.3",
|
"better-auth": "^1.5.3",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"framer-motion": "^13.2.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -7934,6 +7935,29 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
@@ -8962,6 +8986,21 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -10610,9 +10649,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.23.13",
|
"version": "4.23.13",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"better-auth": "^1.5.3",
|
"better-auth": "^1.5.3",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"framer-motion": "^13.2.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
Executable
+227
@@ -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."
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forgetButton {
|
||||||
|
align-self: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { authClient } from '#/lib/auth-client.ts'
|
import { 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 { placeOrder } from '#/server/orders.ts'
|
||||||
|
|
||||||
import styles from './CheckoutSection.module.scss'
|
import styles from './CheckoutSection.module.scss'
|
||||||
@@ -25,14 +32,36 @@ export const CheckoutSection = ({
|
|||||||
const { data: session, isPending } = authClient.useSession()
|
const { data: session, isPending } = authClient.useSession()
|
||||||
const [guestName, setGuestName] = useState('')
|
const [guestName, setGuestName] = useState('')
|
||||||
const [guestContact, setGuestContact] = useState('')
|
const [guestContact, setGuestContact] = useState('')
|
||||||
|
const [rememberedGuest, setRememberedGuest] = useState<GuestIdentity | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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) => {
|
const submit = async (customer: Customer) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const order = await placeOrder({ data: { items, customer } })
|
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)
|
onPlaced(order)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
className={styles.section}
|
className={styles.section}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
@use '../../styles/variables' as *;
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: $color-text-muted;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $space-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lines {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $space-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-weight: 600;
|
|
||||||
padding-top: $space-2;
|
|
||||||
border-top: 1px solid $color-border;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
|
||||||
|
|
||||||
import styles from './OrderSummary.module.scss'
|
|
||||||
|
|
||||||
type OrderSummaryProps = {
|
|
||||||
menu: Array<MenuItemWithRecipe>
|
|
||||||
quantities: Record<number, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrderSummary = ({ menu, quantities }: OrderSummaryProps) => {
|
|
||||||
const lines = menu
|
|
||||||
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
|
|
||||||
.filter(({ quantity }) => quantity > 0)
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className={styles.empty}>
|
|
||||||
Your order is empty — add something from the menu.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCents = lines.reduce(
|
|
||||||
(sum, { item, quantity }) => sum + item.priceCents * quantity,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.summary}>
|
|
||||||
<ul className={styles.lines}>
|
|
||||||
{lines.map(({ item, quantity }) => (
|
|
||||||
<li key={item.id} className={styles.line}>
|
|
||||||
<span>
|
|
||||||
{quantity} × {item.name}
|
|
||||||
</span>
|
|
||||||
<span>${((item.priceCents * quantity) / 100).toFixed(2)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className={styles.total}>
|
|
||||||
<span>Total</span>
|
|
||||||
<span>${(totalCents / 100).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,31 @@
|
|||||||
padding: $space-5 $space-3;
|
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 {
|
.heading {
|
||||||
margin: 0 0 $space-1;
|
margin: 0 0 $space-1;
|
||||||
}
|
}
|
||||||
@@ -48,22 +73,117 @@
|
|||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.steps {
|
.timeline {
|
||||||
margin: 0 0 $space-2;
|
margin: 0 0 $space-2;
|
||||||
padding-left: $space-4;
|
padding: 0;
|
||||||
font-size: 0.85rem;
|
list-style: none;
|
||||||
color: $color-text-muted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.step {
|
.step {
|
||||||
&[data-status='done'] {
|
position: relative;
|
||||||
text-decoration: line-through;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-status='in_progress'] {
|
.stepMarker {
|
||||||
color: $color-primary;
|
position: relative;
|
||||||
font-weight: 600;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.eta {
|
||||||
@@ -75,12 +195,16 @@
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $space-2;
|
gap: $space-2;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
button {
|
button,
|
||||||
|
a {
|
||||||
padding: $space-1 $space-3;
|
padding: $space-1 $space-3;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -88,4 +212,17 @@
|
|||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import { useRouter } from '@tanstack/react-router'
|
import { Link, useRouter } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import type { OrderStatus } from '#/db/schema.ts'
|
import type { OrderStatus } from '#/db/schema.ts'
|
||||||
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
||||||
|
import { formatEtaMessage } from '#/lib/format.ts'
|
||||||
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
|
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
|
||||||
|
|
||||||
import styles from './OrderStatusView.module.scss'
|
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 = {
|
export type OrderStatusResult = {
|
||||||
id: number
|
id: number
|
||||||
status: OrderStatus
|
status: OrderStatus
|
||||||
@@ -41,8 +66,14 @@ export const OrderStatusView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.page}>
|
<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>
|
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||||
<p className={styles.orderStatus}>Status: {status.status}</p>
|
<p className={styles.orderStatus}>Status: {status.status}</p>
|
||||||
|
</div>
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
<ul className={styles.items}>
|
<ul className={styles.items}>
|
||||||
{status.items.map((item) => (
|
{status.items.map((item) => (
|
||||||
@@ -51,41 +82,43 @@ export const OrderStatusView = ({
|
|||||||
<span className={styles.itemName}>{item.menuItemName}</span>
|
<span className={styles.itemName}>{item.menuItemName}</span>
|
||||||
<span className={styles.itemStatus}>{item.status}</span>
|
<span className={styles.itemStatus}>{item.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<ol className={styles.steps}>
|
<ol className={styles.timeline}>
|
||||||
{item.steps.map((step) => (
|
{item.steps.map((step) => (
|
||||||
<li
|
<li
|
||||||
key={step.sequence}
|
key={step.sequence}
|
||||||
className={styles.step}
|
className={styles.step}
|
||||||
data-status={step.status}
|
data-status={step.status}
|
||||||
|
aria-current={step.status === 'in_progress' ? 'step' : undefined}
|
||||||
>
|
>
|
||||||
{step.machineTag}
|
<span className={styles.stepMarker} aria-hidden="true">
|
||||||
{step.status === 'queued' &&
|
<span className={styles.stepDot} />
|
||||||
step.queuePosition !== null &&
|
</span>
|
||||||
` — #${step.queuePosition} in line`}
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
{item.etaSeconds !== null && (
|
{item.etaSeconds !== null && (
|
||||||
<p className={styles.eta}>
|
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
|
||||||
Ready in about {Math.ceil(item.etaSeconds / 60)} min
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{item.cancellable && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pendingItemId === item.id}
|
disabled={pendingItemId === item.id}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
void runAction(item.id, () =>
|
//
|
||||||
cancelOrderItem({
|
}}
|
||||||
data: { orderItemId: item.id, guestAccessToken },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Buy again
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{item.status === 'ready' && (
|
{item.status === 'ready' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,6 +134,23 @@ export const OrderStatusView = ({
|
|||||||
I picked this up
|
I picked this up
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</li>
|
</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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ export const menuItemSteps = pgTable('menu_item_steps', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => menuItems.id),
|
.references(() => menuItems.id),
|
||||||
sequence: integer().notNull(),
|
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')
|
machineTagId: integer('machine_tag_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => machineTags.id),
|
.references(() => machineTags.id),
|
||||||
@@ -112,6 +116,7 @@ export const orderItemSteps = pgTable('order_item_steps', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orderItems.id),
|
.references(() => orderItems.id),
|
||||||
sequence: integer().notNull(),
|
sequence: integer().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
machineTagId: integer('machine_tag_id')
|
machineTagId: integer('machine_tag_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => machineTags.id),
|
.references(() => machineTags.id),
|
||||||
|
|||||||
+57
-4
@@ -11,6 +11,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
.values({ name: 'Ghost Kitchen' })
|
.values({ name: 'Ghost Kitchen' })
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
const [prepTag] = await db
|
||||||
|
.insert(schema.machineTags)
|
||||||
|
.values({ name: 'prep' })
|
||||||
|
.returning()
|
||||||
const [ovenTag] = await db
|
const [ovenTag] = await db
|
||||||
.insert(schema.machineTags)
|
.insert(schema.machineTags)
|
||||||
.values({ name: 'oven' })
|
.values({ name: 'oven' })
|
||||||
@@ -24,6 +28,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
.values({ name: 'assembly' })
|
.values({ name: 'assembly' })
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
const [prep1] = await db
|
||||||
|
.insert(schema.machines)
|
||||||
|
.values({ name: 'Prep Station 1', machineTagId: prepTag.id })
|
||||||
|
.returning()
|
||||||
const [oven1] = await db
|
const [oven1] = await db
|
||||||
.insert(schema.machines)
|
.insert(schema.machines)
|
||||||
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
|
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
|
||||||
@@ -47,12 +55,50 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
await db.insert(schema.menuItemSteps).values({
|
await db.insert(schema.menuItemSteps).values([
|
||||||
|
{
|
||||||
menuItemId: pizza.id,
|
menuItemId: pizza.id,
|
||||||
sequence: 1,
|
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,
|
machineTagId: ovenTag.id,
|
||||||
estimatedDurationSeconds: 600,
|
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
|
const [burger] = await db
|
||||||
.insert(schema.menuItems)
|
.insert(schema.menuItems)
|
||||||
@@ -68,12 +114,14 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
{
|
{
|
||||||
menuItemId: burger.id,
|
menuItemId: burger.id,
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
|
name: 'Grilling patty',
|
||||||
machineTagId: grillTag.id,
|
machineTagId: grillTag.id,
|
||||||
estimatedDurationSeconds: 240,
|
estimatedDurationSeconds: 240,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
menuItemId: burger.id,
|
menuItemId: burger.id,
|
||||||
sequence: 2,
|
sequence: 2,
|
||||||
|
name: 'Assembling burger',
|
||||||
machineTagId: assemblyTag.id,
|
machineTagId: assemblyTag.id,
|
||||||
estimatedDurationSeconds: 90,
|
estimatedDurationSeconds: 90,
|
||||||
},
|
},
|
||||||
@@ -81,8 +129,13 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
brand,
|
brand,
|
||||||
machineTags: { oven: ovenTag, grill: grillTag, assembly: assemblyTag },
|
machineTags: {
|
||||||
machines: { oven1, grill1, assembly1 },
|
prep: prepTag,
|
||||||
|
oven: ovenTag,
|
||||||
|
grill: grillTag,
|
||||||
|
assembly: assemblyTag,
|
||||||
|
},
|
||||||
|
machines: { prep1, oven1, grill1, assembly1 },
|
||||||
menuItems: { pizza, burger },
|
menuItems: { pizza, burger },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-2
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, eq, inArray } from 'drizzle-orm'
|
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
import * as schema from '#/db/schema.ts'
|
import * as schema from '#/db/schema.ts'
|
||||||
@@ -53,6 +53,7 @@ export type MenuItemWithRecipe = {
|
|||||||
priceCents: number
|
priceCents: number
|
||||||
steps: Array<{
|
steps: Array<{
|
||||||
sequence: number
|
sequence: number
|
||||||
|
name: string
|
||||||
machineTag: string
|
machineTag: string
|
||||||
estimatedDurationSeconds: number
|
estimatedDurationSeconds: number
|
||||||
}>
|
}>
|
||||||
@@ -71,6 +72,7 @@ export const getMenu = async (
|
|||||||
.select({
|
.select({
|
||||||
menuItemId: schema.menuItemSteps.menuItemId,
|
menuItemId: schema.menuItemSteps.menuItemId,
|
||||||
sequence: schema.menuItemSteps.sequence,
|
sequence: schema.menuItemSteps.sequence,
|
||||||
|
name: schema.menuItemSteps.name,
|
||||||
machineTag: schema.machineTags.name,
|
machineTag: schema.machineTags.name,
|
||||||
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
||||||
})
|
})
|
||||||
@@ -94,8 +96,9 @@ export const getMenu = async (
|
|||||||
priceCents: item.priceCents,
|
priceCents: item.priceCents,
|
||||||
steps: steps
|
steps: steps
|
||||||
.filter((step) => step.menuItemId === item.id)
|
.filter((step) => step.menuItemId === item.id)
|
||||||
.map(({ sequence, machineTag, estimatedDurationSeconds }) => ({
|
.map(({ sequence, name, machineTag, estimatedDurationSeconds }) => ({
|
||||||
sequence,
|
sequence,
|
||||||
|
name,
|
||||||
machineTag,
|
machineTag,
|
||||||
estimatedDurationSeconds,
|
estimatedDurationSeconds,
|
||||||
})),
|
})),
|
||||||
@@ -199,6 +202,7 @@ const initializeOrderItemRecipe = async (
|
|||||||
liveSteps.map((step) => ({
|
liveSteps.map((step) => ({
|
||||||
orderItemId,
|
orderItemId,
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTagId: step.machineTagId,
|
machineTagId: step.machineTagId,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
})),
|
})),
|
||||||
@@ -582,6 +586,7 @@ export type OrderItemStatusView = {
|
|||||||
cancellable: boolean
|
cancellable: boolean
|
||||||
steps: Array<{
|
steps: Array<{
|
||||||
sequence: number
|
sequence: number
|
||||||
|
name: string
|
||||||
machineTag: string
|
machineTag: string
|
||||||
estimatedDurationSeconds: number
|
estimatedDurationSeconds: number
|
||||||
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
||||||
@@ -625,6 +630,7 @@ export const getOrderStatus = async (
|
|||||||
.select({
|
.select({
|
||||||
orderItemId: schema.orderItemSteps.orderItemId,
|
orderItemId: schema.orderItemSteps.orderItemId,
|
||||||
sequence: schema.orderItemSteps.sequence,
|
sequence: schema.orderItemSteps.sequence,
|
||||||
|
name: schema.orderItemSteps.name,
|
||||||
machineTag: schema.machineTags.name,
|
machineTag: schema.machineTags.name,
|
||||||
machineTagId: schema.orderItemSteps.machineTagId,
|
machineTagId: schema.orderItemSteps.machineTagId,
|
||||||
estimatedDurationSeconds:
|
estimatedDurationSeconds:
|
||||||
@@ -710,6 +716,7 @@ export const getOrderStatus = async (
|
|||||||
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
|
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
@@ -720,6 +727,7 @@ export const getOrderStatus = async (
|
|||||||
if (instance.status === 'done') {
|
if (instance.status === 'done') {
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'done' as const,
|
status: 'done' as const,
|
||||||
@@ -739,6 +747,7 @@ export const getOrderStatus = async (
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'in_progress' as const,
|
status: 'in_progress' as const,
|
||||||
@@ -765,6 +774,7 @@ export const getOrderStatus = async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'queued' as const,
|
status: 'queued' as const,
|
||||||
@@ -802,3 +812,114 @@ export const getOrderStatus = async (
|
|||||||
items: itemViews,
|
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 *;
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
.avatar {
|
.wrapper {
|
||||||
height: 2rem;
|
position: relative;
|
||||||
width: 2rem;
|
|
||||||
background: $color-surface;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatarPulse {
|
.trigger {
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarInitial {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
height: 2.25rem;
|
||||||
padding: 0 $space-3;
|
width: 2.25rem;
|
||||||
font-size: 0.875rem;
|
padding: 0;
|
||||||
font-weight: 500;
|
|
||||||
background: $color-background;
|
|
||||||
color: $color-text;
|
|
||||||
border: 1px solid $color-border;
|
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;
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-surface;
|
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 {
|
@keyframes pulse {
|
||||||
|
|||||||
@@ -1,37 +1,151 @@
|
|||||||
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { authClient } from '#/lib/auth-client'
|
import { authClient } from '#/lib/auth-client'
|
||||||
|
|
||||||
import styles from './header-user.module.scss'
|
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 BetterAuthHeader = () => {
|
||||||
const { data: session, isPending } = authClient.useSession()
|
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) {
|
if (isPending) {
|
||||||
return <div className={`${styles.avatar} ${styles.avatarPulse}`} />
|
return <div className={`${styles.trigger} ${styles.pulse}`} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session?.user) {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.session}>
|
<div className={styles.wrapper} ref={wrapperRef}>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<button
|
<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={() => {
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
void authClient.signOut()
|
void authClient.signOut()
|
||||||
}}
|
}}
|
||||||
className={styles.signOut}
|
|
||||||
>
|
>
|
||||||
|
<LogOutIcon />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BetterAuthHeader
|
export default BetterAuthHeader
|
||||||
|
|||||||
@@ -2,10 +2,30 @@ import { betterAuth } from 'better-auth'
|
|||||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||||
import { Pool } from 'pg'
|
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({
|
export const auth = betterAuth({
|
||||||
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
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()],
|
plugins: [tanstackStartCookies()],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
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 OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
|
|
||||||
@@ -18,6 +22,26 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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({
|
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
|
||||||
id: '/orders/$orderId',
|
id: '/orders/$orderId',
|
||||||
path: '/orders/$orderId',
|
path: '/orders/$orderId',
|
||||||
@@ -31,31 +55,69 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/basket': typeof BasketRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/orders/': typeof OrdersIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/basket': typeof BasketRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/orders': typeof OrdersIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/basket': typeof BasketRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/orders/': typeof OrdersIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/orders/$orderId' | '/api/auth/$'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/basket'
|
||||||
|
| '/login'
|
||||||
|
| '/signup'
|
||||||
|
| '/orders/$orderId'
|
||||||
|
| '/orders/'
|
||||||
|
| '/api/auth/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/orders/$orderId' | '/api/auth/$'
|
to:
|
||||||
id: '__root__' | '/' | '/orders/$orderId' | '/api/auth/$'
|
| '/'
|
||||||
|
| '/basket'
|
||||||
|
| '/login'
|
||||||
|
| '/signup'
|
||||||
|
| '/orders/$orderId'
|
||||||
|
| '/orders'
|
||||||
|
| '/api/auth/$'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/basket'
|
||||||
|
| '/login'
|
||||||
|
| '/signup'
|
||||||
|
| '/orders/$orderId'
|
||||||
|
| '/orders/'
|
||||||
|
| '/api/auth/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
BasketRoute: typeof BasketRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
SignupRoute: typeof SignupRoute
|
||||||
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
||||||
|
OrdersIndexRoute: typeof OrdersIndexRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +130,34 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/orders/$orderId': {
|
||||||
id: '/orders/$orderId'
|
id: '/orders/$orderId'
|
||||||
path: '/orders/$orderId'
|
path: '/orders/$orderId'
|
||||||
@@ -87,7 +177,11 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
BasketRoute: BasketRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
|
SignupRoute: SignupRoute,
|
||||||
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
||||||
|
OrdersIndexRoute: OrdersIndexRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const getRouter = () => {
|
|||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultViewTransition: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+46
-3
@@ -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 { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
import { TanStackDevtools } from '@tanstack/react-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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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}
|
{children}
|
||||||
|
</BasketProvider>
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
@@ -26,8 +55,10 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
|
loader: async () => ({ brand: await getBrand() }),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
@@ -41,6 +72,18 @@ export const Route = createRootRoute({
|
|||||||
title: 'Ghost Kitchen',
|
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,
|
shellComponent: RootDocument,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,32 +1,7 @@
|
|||||||
@use '../styles/variables' as *;
|
@use '../styles/variables' as *;
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 960px;
|
max-width: 640px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: $space-5 $space-3;
|
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
@@ -1,69 +1,24 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
|
||||||
import { MenuList } from '#/components/ordering/MenuList.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 { getBrand } from '#/server/brand.ts'
|
||||||
import { getMenu } from '#/server/menu.ts'
|
import { getMenu } from '#/server/menu.ts'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
const OrderingPage = () => {
|
const OrderingPage = () => {
|
||||||
const { brand, menu } = Route.useLoaderData()
|
const { menu } = Route.useLoaderData()
|
||||||
const navigate = useNavigate()
|
const { quantities, add, remove } = useBasket()
|
||||||
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),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
<h1 className={styles.heading}>{brand.name}</h1>
|
|
||||||
<div className={styles.layout}>
|
|
||||||
<MenuList
|
<MenuList
|
||||||
menu={menu}
|
menu={menu}
|
||||||
quantities={quantities}
|
quantities={quantities}
|
||||||
onAdd={add}
|
onAdd={add}
|
||||||
onRemove={remove}
|
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>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,8 +26,7 @@ const OrderingPage = () => {
|
|||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const brand = await getBrand()
|
const brand = await getBrand()
|
||||||
const menu = await getMenu({ data: { brandId: brand.id } })
|
return { menu: await getMenu({ data: { brandId: brand.id } }) }
|
||||||
return { brand, menu }
|
|
||||||
},
|
},
|
||||||
component: OrderingPage,
|
component: OrderingPage,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'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,
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -62,6 +62,28 @@ export const getOrderStatus = createServerFn({ method: 'GET' })
|
|||||||
ordering.getOrderStatus(db, data.orderId, await resolveAccess(data)),
|
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' })
|
export const cancelOrderItem = createServerFn({ method: 'POST' })
|
||||||
.validator(accessSchema.extend({ orderItemId: z.number() }))
|
.validator(accessSchema.extend({ orderItemId: z.number() }))
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
|
|||||||
@@ -21,3 +21,80 @@ body {
|
|||||||
button {
|
button {
|
||||||
font: inherit;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -51,6 +51,15 @@ const placeGuestOrder = (items: Array<{ menuItemId: number }>) =>
|
|||||||
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
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
|
// 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
|
// token exactly like a real caller would — this exercises the same
|
||||||
// ownership check a stranger guessing the order id would fail.
|
// ownership check a stranger guessing the order id would fail.
|
||||||
@@ -67,13 +76,58 @@ describe('menu', () => {
|
|||||||
|
|
||||||
const pizza = menu.find((item) => item.name === 'Pizza')
|
const pizza = menu.find((item) => item.name === 'Pizza')
|
||||||
expect(pizza?.steps).toEqual([
|
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')
|
const burger = menu.find((item) => item.name === 'Burger')
|
||||||
expect(burger?.steps).toEqual([
|
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(
|
const pizzaItem = status.items.find(
|
||||||
(item) => item.menuItemName === 'Pizza',
|
(item) => item.menuItemName === 'Pizza',
|
||||||
)!
|
)!
|
||||||
// Only one oven, but the pizza is the only thing waiting for it, so it
|
// Only one prep station, but the pizza is the only thing waiting for
|
||||||
// starts immediately.
|
// it, so its first Step starts immediately.
|
||||||
expect(pizzaItem.steps[0].status).toBe('in_progress')
|
expect(pizzaItem.steps[0].status).toBe('in_progress')
|
||||||
expect(pizzaItem.cancellable).toBe(false)
|
expect(pizzaItem.cancellable).toBe(false)
|
||||||
|
|
||||||
@@ -192,7 +246,7 @@ describe('placing an order', () => {
|
|||||||
|
|
||||||
describe('cancelling an Order Item', () => {
|
describe('cancelling an Order Item', () => {
|
||||||
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
|
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([
|
const order = await placeGuestOrder([
|
||||||
{ menuItemId: seed.menuItems.pizza.id },
|
{ menuItemId: seed.menuItems.pizza.id },
|
||||||
{ menuItemId: seed.menuItems.burger.id },
|
{ menuItemId: seed.menuItems.burger.id },
|
||||||
@@ -205,7 +259,7 @@ describe('cancelling an Order Item', () => {
|
|||||||
const burgerItem = before.items.find(
|
const burgerItem = before.items.find(
|
||||||
(item) => item.menuItemName === 'Burger',
|
(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
|
expect(burgerItem.steps[0].status).toBe('in_progress') // grill was free
|
||||||
|
|
||||||
await ordering.cancelOrderItem(
|
await ordering.cancelOrderItem(
|
||||||
@@ -231,7 +285,7 @@ describe('cancelling an Order Item', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('completes the order once its only item is cancelled', async () => {
|
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([
|
const order = await placeGuestOrder([
|
||||||
{ menuItemId: seed.menuItems.pizza.id },
|
{ 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 () => {
|
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([
|
const order = await placeGuestOrder([
|
||||||
{ menuItemId: seed.menuItems.pizza.id },
|
{ 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 () => {
|
it('surfaces the original cancellation failure even if the compensating re-charge also fails', async () => {
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
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([
|
const order = await placeGuestOrder([
|
||||||
{ menuItemId: seed.menuItems.pizza.id },
|
{ menuItemId: seed.menuItems.pizza.id },
|
||||||
])
|
])
|
||||||
@@ -417,8 +471,13 @@ describe('advancing steps and pickup', () => {
|
|||||||
|
|
||||||
await ordering.advanceOrderItemStep(testDb.db, firstItemId)
|
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)
|
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)
|
secondStatus = await getStatus(secondOrder)
|
||||||
expect(secondStatus.items[0].steps[0].status).toBe('in_progress')
|
expect(secondStatus.items[0].steps[0].status).toBe('in_progress')
|
||||||
@@ -498,3 +557,64 @@ describe('access control', () => {
|
|||||||
).rejects.toThrow(ordering.ForbiddenError)
|
).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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import viteReact from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
resolve: { tsconfigPaths: true },
|
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()],
|
plugins: [devtools(), tanstackStart(), viteReact()],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user