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
|
||||
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
|
||||
|
||||
Generate and set the `BETTER_AUTH_SECRET` environment variable in your
|
||||
`.env.local`:
|
||||
`.env.local`, and make sure `BETTER_AUTH_URL` matches the URL the app is
|
||||
actually served from (`http://localhost:3000` in local dev):
|
||||
|
||||
```bash
|
||||
npx @better-auth/cli secret
|
||||
```
|
||||
|
||||
See the [Better Auth documentation](https://www.better-auth.com) for more on
|
||||
configuring authentication.
|
||||
Account creation and sign-in live at `/signup` and `/login`, supporting both
|
||||
email/password and "Continue with Google" / "Continue with Apple" SSO. The
|
||||
SSO buttons only appear once the corresponding provider's credentials are
|
||||
set in `.env.local`:
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=...
|
||||
GOOGLE_CLIENT_SECRET=...
|
||||
APPLE_CLIENT_ID=...
|
||||
APPLE_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
Create OAuth credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
|
||||
and the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list/serviceId)
|
||||
respectively, with the redirect URI `{BETTER_AUTH_URL}/api/auth/callback/google`
|
||||
(swap `google` for `apple`). See the
|
||||
[Better Auth documentation](https://www.better-auth.com) for more on
|
||||
configuring authentication, and its
|
||||
[Google](https://www.better-auth.com/docs/authentication/google) and
|
||||
[Apple](https://www.better-auth.com/docs/authentication/apple) provider
|
||||
guides for provider-specific setup details.
|
||||
|
||||
For Google, `scripts/setup-google-sso.sh` walks you through the Google Cloud
|
||||
Console steps interactively and writes the resulting `GOOGLE_CLIENT_ID` /
|
||||
`GOOGLE_CLIENT_SECRET` into `.env` for you:
|
||||
|
||||
```bash
|
||||
./scripts/setup-google-sso.sh
|
||||
```
|
||||
|
||||
Apple's Sign in with Apple doesn't support `localhost` redirect URIs — you
|
||||
need a real HTTPS domain (a deployment or a tunnel like ngrok) registered on
|
||||
the Services ID before it's testable, so there's no equivalent script for it
|
||||
yet.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
"tag": "0002_large_lake",
|
||||
"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",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^13.2.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -7934,6 +7935,29 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "13.2.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.2.0.tgz",
|
||||
"integrity": "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^13.2.0",
|
||||
"motion-utils": "^13.0.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@@ -8962,6 +8986,21 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "13.2.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.2.0.tgz",
|
||||
"integrity": "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz",
|
||||
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -10610,9 +10649,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"better-auth": "^1.5.3",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^13.2.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
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;
|
||||
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 {
|
||||
clearGuestIdentity,
|
||||
getGuestIdentity,
|
||||
saveGuestIdentity,
|
||||
} from '#/lib/guestIdentity.ts'
|
||||
import type { GuestIdentity } from '#/lib/guestIdentity.ts'
|
||||
import { addGuestOrderRef } from '#/lib/guestOrders.ts'
|
||||
import { placeOrder } from '#/server/orders.ts'
|
||||
|
||||
import styles from './CheckoutSection.module.scss'
|
||||
@@ -25,14 +32,36 @@ export const CheckoutSection = ({
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const [guestName, setGuestName] = useState('')
|
||||
const [guestContact, setGuestContact] = useState('')
|
||||
const [rememberedGuest, setRememberedGuest] = useState<GuestIdentity | null>(
|
||||
null,
|
||||
)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Read after mount, not during initial render, so the server-rendered and
|
||||
// first client-rendered HTML match (no localStorage on the server) and
|
||||
// React doesn't flag a hydration mismatch — same reasoning as
|
||||
// BasketProvider.
|
||||
useEffect(() => {
|
||||
setRememberedGuest(getGuestIdentity())
|
||||
}, [])
|
||||
|
||||
const submit = async (customer: Customer) => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const order = await placeOrder({ data: { items, customer } })
|
||||
if (order.guestAccessToken) {
|
||||
addGuestOrderRef({
|
||||
orderId: order.id,
|
||||
guestAccessToken: order.guestAccessToken,
|
||||
})
|
||||
}
|
||||
if (customer.type === 'guest') {
|
||||
const identity = { name: customer.name, contact: customer.contact }
|
||||
saveGuestIdentity(identity)
|
||||
setRememberedGuest(identity)
|
||||
}
|
||||
onPlaced(order)
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -63,6 +92,34 @@ export const CheckoutSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (rememberedGuest) {
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitButton}
|
||||
disabled={disabled || submitting}
|
||||
onClick={() => submit({ type: 'guest', ...rememberedGuest })}
|
||||
>
|
||||
{submitting
|
||||
? 'Placing order…'
|
||||
: `Place order as ${rememberedGuest.name}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.forgetButton}
|
||||
onClick={() => {
|
||||
clearGuestIdentity()
|
||||
setRememberedGuest(null)
|
||||
}}
|
||||
>
|
||||
Not you?
|
||||
</button>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.section}
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
border: 1px solid $color-border;
|
||||
background: $color-background;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.empty {
|
||||
color: $color-text-muted;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.lines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
padding-top: $space-2;
|
||||
border-top: 1px solid $color-border;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-1;
|
||||
margin: 0 0 $space-3;
|
||||
color: $color-text-muted;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
|
||||
.backArrow {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0 0 $space-1;
|
||||
}
|
||||
@@ -48,22 +73,117 @@
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.steps {
|
||||
.timeline {
|
||||
margin: 0 0 $space-2;
|
||||
padding-left: $space-4;
|
||||
font-size: 0.85rem;
|
||||
color: $color-text-muted;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.step {
|
||||
&[data-status='done'] {
|
||||
text-decoration: line-through;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: $space-3 1fr;
|
||||
column-gap: $space-2;
|
||||
padding-bottom: $space-3;
|
||||
font-size: 0.85rem;
|
||||
color: $color-text-muted;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stepMarker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 3px;
|
||||
border: 2px solid $color-border;
|
||||
border-radius: 50%;
|
||||
background: $color-background;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stepMarker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
bottom: -$space-3;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
background: $color-border;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.step:last-child .stepMarker::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.stepMeta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.step[data-status='done'] {
|
||||
.stepDot {
|
||||
border-color: $color-primary;
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
&[data-status='in_progress'] {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
.stepMarker::after {
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
color: $color-text;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.step[data-status='in_progress'] {
|
||||
.stepDot {
|
||||
border-color: $color-success;
|
||||
background: $color-success;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: $color-success;
|
||||
animation: sonarPing 1.6s ease-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sonarPing {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(2.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.step[data-status='pending'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.eta {
|
||||
@@ -75,12 +195,16 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
button,
|
||||
a {
|
||||
padding: $space-1 $space-3;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius;
|
||||
background: $color-background;
|
||||
color: $color-text;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
@@ -88,4 +212,17 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
padding: $space-1 $space-2;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $color-text-muted;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import { Link, useRouter } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { OrderStatus } from '#/db/schema.ts'
|
||||
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
||||
import { formatEtaMessage } from '#/lib/format.ts'
|
||||
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
|
||||
|
||||
import styles from './OrderStatusView.module.scss'
|
||||
|
||||
const SUPPORT_EMAIL = 'help@ghostkitchen.example'
|
||||
|
||||
const helpMailto = (orderId: number, item: OrderItemStatusView) => {
|
||||
const subject = `Help with Order #${orderId} — ${item.menuItemName}`
|
||||
const body = `Order #${orderId}\nItem: ${item.menuItemName}\nStatus: ${item.status}\n\nWhat's going on?\n`
|
||||
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
|
||||
}
|
||||
|
||||
const BackArrowIcon = () => (
|
||||
<svg
|
||||
className={styles.backArrow}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M19 12H5" />
|
||||
<path d="M11 18l-6-6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export type OrderStatusResult = {
|
||||
id: number
|
||||
status: OrderStatus
|
||||
@@ -41,8 +66,14 @@ export const OrderStatusView = ({
|
||||
|
||||
return (
|
||||
<section className={styles.page}>
|
||||
<Link to="/orders" className={styles.backLink}>
|
||||
<BackArrowIcon />
|
||||
Back to orders
|
||||
</Link>
|
||||
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
||||
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||
<p className={styles.orderStatus}>Status: {status.status}</p>
|
||||
</div>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<ul className={styles.items}>
|
||||
{status.items.map((item) => (
|
||||
@@ -51,41 +82,43 @@ export const OrderStatusView = ({
|
||||
<span className={styles.itemName}>{item.menuItemName}</span>
|
||||
<span className={styles.itemStatus}>{item.status}</span>
|
||||
</div>
|
||||
<ol className={styles.steps}>
|
||||
<ol className={styles.timeline}>
|
||||
{item.steps.map((step) => (
|
||||
<li
|
||||
key={step.sequence}
|
||||
className={styles.step}
|
||||
data-status={step.status}
|
||||
aria-current={step.status === 'in_progress' ? 'step' : undefined}
|
||||
>
|
||||
{step.machineTag}
|
||||
{step.status === 'queued' &&
|
||||
step.queuePosition !== null &&
|
||||
` — #${step.queuePosition} in line`}
|
||||
<span className={styles.stepMarker} aria-hidden="true">
|
||||
<span className={styles.stepDot} />
|
||||
</span>
|
||||
<span className={styles.stepContent}>
|
||||
<span className={styles.stepLabel}>{step.name}</span>
|
||||
{step.status === 'queued' && step.queuePosition !== null && (
|
||||
<span className={styles.stepMeta}>
|
||||
#{step.queuePosition} in line
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{item.etaSeconds !== null && (
|
||||
<p className={styles.eta}>
|
||||
Ready in about {Math.ceil(item.etaSeconds / 60)} min
|
||||
</p>
|
||||
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
{item.cancellable && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingItemId === item.id}
|
||||
onClick={() =>
|
||||
void runAction(item.id, () =>
|
||||
cancelOrderItem({
|
||||
data: { orderItemId: item.id, guestAccessToken },
|
||||
}),
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
Buy again
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{item.status === 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,6 +134,23 @@ export const OrderStatusView = ({
|
||||
I picked this up
|
||||
</button>
|
||||
)}
|
||||
<a href={helpMailto(status.id, item)}>Get help</a>
|
||||
{item.cancellable && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelButton}
|
||||
disabled={pendingItemId === item.id}
|
||||
onClick={() =>
|
||||
void runAction(item.id, () =>
|
||||
cancelOrderItem({
|
||||
data: { orderItemId: item.id, guestAccessToken },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: $space-5 $space-3;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0 0 $space-4;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: $color-text-muted;
|
||||
|
||||
a {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.orders {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.order {
|
||||
display: block;
|
||||
padding: $space-3;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius;
|
||||
background: $color-surface;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.orderHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.orderStatus {
|
||||
text-transform: capitalize;
|
||||
color: $color-text-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.orderItems {
|
||||
margin: 0 0 $space-1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.orderMeta {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
@@ -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()
|
||||
.references(() => menuItems.id),
|
||||
sequence: integer().notNull(),
|
||||
// Customer-facing label, e.g. "Flattening dough" — distinct from the
|
||||
// Machine Tag because several Steps in a Recipe can share one Tag (and
|
||||
// so the same physical Machine) while doing different work on it.
|
||||
name: text().notNull(),
|
||||
machineTagId: integer('machine_tag_id')
|
||||
.notNull()
|
||||
.references(() => machineTags.id),
|
||||
@@ -112,6 +116,7 @@ export const orderItemSteps = pgTable('order_item_steps', {
|
||||
.notNull()
|
||||
.references(() => orderItems.id),
|
||||
sequence: integer().notNull(),
|
||||
name: text().notNull(),
|
||||
machineTagId: integer('machine_tag_id')
|
||||
.notNull()
|
||||
.references(() => machineTags.id),
|
||||
|
||||
+57
-4
@@ -11,6 +11,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
||||
.values({ name: 'Ghost Kitchen' })
|
||||
.returning()
|
||||
|
||||
const [prepTag] = await db
|
||||
.insert(schema.machineTags)
|
||||
.values({ name: 'prep' })
|
||||
.returning()
|
||||
const [ovenTag] = await db
|
||||
.insert(schema.machineTags)
|
||||
.values({ name: 'oven' })
|
||||
@@ -24,6 +28,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
||||
.values({ name: 'assembly' })
|
||||
.returning()
|
||||
|
||||
const [prep1] = await db
|
||||
.insert(schema.machines)
|
||||
.values({ name: 'Prep Station 1', machineTagId: prepTag.id })
|
||||
.returning()
|
||||
const [oven1] = await db
|
||||
.insert(schema.machines)
|
||||
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
|
||||
@@ -47,12 +55,50 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
||||
})
|
||||
.returning()
|
||||
|
||||
await db.insert(schema.menuItemSteps).values({
|
||||
await db.insert(schema.menuItemSteps).values([
|
||||
{
|
||||
menuItemId: pizza.id,
|
||||
sequence: 1,
|
||||
name: 'Flattening dough',
|
||||
machineTagId: prepTag.id,
|
||||
estimatedDurationSeconds: 30,
|
||||
},
|
||||
{
|
||||
menuItemId: pizza.id,
|
||||
sequence: 2,
|
||||
name: 'Spreading sauce',
|
||||
machineTagId: prepTag.id,
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
{
|
||||
menuItemId: pizza.id,
|
||||
sequence: 3,
|
||||
name: 'Adding toppings',
|
||||
machineTagId: prepTag.id,
|
||||
estimatedDurationSeconds: 30,
|
||||
},
|
||||
{
|
||||
menuItemId: pizza.id,
|
||||
sequence: 4,
|
||||
name: 'Baking in the oven',
|
||||
machineTagId: ovenTag.id,
|
||||
estimatedDurationSeconds: 600,
|
||||
})
|
||||
},
|
||||
{
|
||||
menuItemId: pizza.id,
|
||||
sequence: 5,
|
||||
name: 'Slicing',
|
||||
machineTagId: assemblyTag.id,
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
{
|
||||
menuItemId: pizza.id,
|
||||
sequence: 6,
|
||||
name: 'Packaging',
|
||||
machineTagId: assemblyTag.id,
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
])
|
||||
|
||||
const [burger] = await db
|
||||
.insert(schema.menuItems)
|
||||
@@ -68,12 +114,14 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
||||
{
|
||||
menuItemId: burger.id,
|
||||
sequence: 1,
|
||||
name: 'Grilling patty',
|
||||
machineTagId: grillTag.id,
|
||||
estimatedDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
menuItemId: burger.id,
|
||||
sequence: 2,
|
||||
name: 'Assembling burger',
|
||||
machineTagId: assemblyTag.id,
|
||||
estimatedDurationSeconds: 90,
|
||||
},
|
||||
@@ -81,8 +129,13 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
||||
|
||||
return {
|
||||
brand,
|
||||
machineTags: { oven: ovenTag, grill: grillTag, assembly: assemblyTag },
|
||||
machines: { oven1, grill1, assembly1 },
|
||||
machineTags: {
|
||||
prep: prepTag,
|
||||
oven: ovenTag,
|
||||
grill: grillTag,
|
||||
assembly: assemblyTag,
|
||||
},
|
||||
machines: { prep1, oven1, grill1, assembly1 },
|
||||
menuItems: { pizza, burger },
|
||||
}
|
||||
}
|
||||
|
||||
+123
-2
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm'
|
||||
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
|
||||
import * as schema from '#/db/schema.ts'
|
||||
@@ -53,6 +53,7 @@ export type MenuItemWithRecipe = {
|
||||
priceCents: number
|
||||
steps: Array<{
|
||||
sequence: number
|
||||
name: string
|
||||
machineTag: string
|
||||
estimatedDurationSeconds: number
|
||||
}>
|
||||
@@ -71,6 +72,7 @@ export const getMenu = async (
|
||||
.select({
|
||||
menuItemId: schema.menuItemSteps.menuItemId,
|
||||
sequence: schema.menuItemSteps.sequence,
|
||||
name: schema.menuItemSteps.name,
|
||||
machineTag: schema.machineTags.name,
|
||||
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
||||
})
|
||||
@@ -94,8 +96,9 @@ export const getMenu = async (
|
||||
priceCents: item.priceCents,
|
||||
steps: steps
|
||||
.filter((step) => step.menuItemId === item.id)
|
||||
.map(({ sequence, machineTag, estimatedDurationSeconds }) => ({
|
||||
.map(({ sequence, name, machineTag, estimatedDurationSeconds }) => ({
|
||||
sequence,
|
||||
name,
|
||||
machineTag,
|
||||
estimatedDurationSeconds,
|
||||
})),
|
||||
@@ -199,6 +202,7 @@ const initializeOrderItemRecipe = async (
|
||||
liveSteps.map((step) => ({
|
||||
orderItemId,
|
||||
sequence: step.sequence,
|
||||
name: step.name,
|
||||
machineTagId: step.machineTagId,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
})),
|
||||
@@ -582,6 +586,7 @@ export type OrderItemStatusView = {
|
||||
cancellable: boolean
|
||||
steps: Array<{
|
||||
sequence: number
|
||||
name: string
|
||||
machineTag: string
|
||||
estimatedDurationSeconds: number
|
||||
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
||||
@@ -625,6 +630,7 @@ export const getOrderStatus = async (
|
||||
.select({
|
||||
orderItemId: schema.orderItemSteps.orderItemId,
|
||||
sequence: schema.orderItemSteps.sequence,
|
||||
name: schema.orderItemSteps.name,
|
||||
machineTag: schema.machineTags.name,
|
||||
machineTagId: schema.orderItemSteps.machineTagId,
|
||||
estimatedDurationSeconds:
|
||||
@@ -710,6 +716,7 @@ export const getOrderStatus = async (
|
||||
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
name: step.name,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'pending' as const,
|
||||
@@ -720,6 +727,7 @@ export const getOrderStatus = async (
|
||||
if (instance.status === 'done') {
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
name: step.name,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'done' as const,
|
||||
@@ -739,6 +747,7 @@ export const getOrderStatus = async (
|
||||
}
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
name: step.name,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'in_progress' as const,
|
||||
@@ -765,6 +774,7 @@ export const getOrderStatus = async (
|
||||
|
||||
return {
|
||||
sequence: step.sequence,
|
||||
name: step.name,
|
||||
machineTag: step.machineTag,
|
||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||
status: 'queued' as const,
|
||||
@@ -802,3 +812,114 @@ export const getOrderStatus = async (
|
||||
items: itemViews,
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomerOrderSummary = {
|
||||
id: number
|
||||
status: schema.OrderStatus
|
||||
createdAt: Date
|
||||
totalCents: number
|
||||
items: Array<{ menuItemName: string; quantity: number }>
|
||||
// Only set for a guest Order — its detail page needs this back as a
|
||||
// search param to prove access, since there's no session to check.
|
||||
guestAccessToken: string | null
|
||||
}
|
||||
|
||||
const summarizeOrders = async (
|
||||
db: Db,
|
||||
orders: Array<typeof schema.orders.$inferSelect>,
|
||||
): Promise<Array<CustomerOrderSummary>> => {
|
||||
if (orders.length === 0) return []
|
||||
|
||||
const items = await db
|
||||
.select({
|
||||
orderId: schema.orderItems.orderId,
|
||||
menuItemName: schema.menuItems.name,
|
||||
priceCents: schema.orderItems.priceCents,
|
||||
})
|
||||
.from(schema.orderItems)
|
||||
.innerJoin(
|
||||
schema.menuItems,
|
||||
eq(schema.orderItems.menuItemId, schema.menuItems.id),
|
||||
)
|
||||
.where(
|
||||
inArray(
|
||||
schema.orderItems.orderId,
|
||||
orders.map((order) => order.id),
|
||||
),
|
||||
)
|
||||
|
||||
return orders.map((order) => {
|
||||
const orderItems = items.filter((item) => item.orderId === order.id)
|
||||
|
||||
const quantityByMenuItemName = new Map<string, number>()
|
||||
for (const item of orderItems) {
|
||||
quantityByMenuItemName.set(
|
||||
item.menuItemName,
|
||||
(quantityByMenuItemName.get(item.menuItemName) ?? 0) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
createdAt: order.createdAt,
|
||||
totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0),
|
||||
items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({
|
||||
menuItemName,
|
||||
quantity,
|
||||
})),
|
||||
guestAccessToken: order.guestAccessToken,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A signed-in customer's own Order history — guest Orders aren't included
|
||||
// since they have no account to list under, only a per-Order access token
|
||||
// (see getGuestOrderSummaries for how a guest's own history is looked up).
|
||||
export const getOrdersForCustomer = async (
|
||||
db: Db,
|
||||
customerId: string,
|
||||
): Promise<Array<CustomerOrderSummary>> => {
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(schema.orders)
|
||||
.where(eq(schema.orders.customerId, customerId))
|
||||
.orderBy(desc(schema.orders.createdAt))
|
||||
|
||||
return summarizeOrders(db, orders)
|
||||
}
|
||||
|
||||
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
|
||||
|
||||
// A guest's Order history, looked up from the (orderId, guestAccessToken)
|
||||
// pairs their browser remembered locally after each placement — there's no
|
||||
// account to query by, so each ref's token stands in for one. A ref whose
|
||||
// token doesn't match (stale, tampered, or the Order somehow isn't a guest
|
||||
// Order) is silently dropped rather than failing the whole lookup.
|
||||
export const getGuestOrderSummaries = async (
|
||||
db: Db,
|
||||
refs: Array<GuestOrderRef>,
|
||||
): Promise<Array<CustomerOrderSummary>> => {
|
||||
if (refs.length === 0) return []
|
||||
|
||||
const tokenByOrderId = new Map(
|
||||
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
|
||||
)
|
||||
|
||||
const orders = await db
|
||||
.select()
|
||||
.from(schema.orders)
|
||||
.where(
|
||||
inArray(schema.orders.id, [...tokenByOrderId.keys()]),
|
||||
)
|
||||
const authorizedOrders = orders.filter(
|
||||
(order) =>
|
||||
order.guestAccessToken !== null &&
|
||||
tokenByOrderId.get(order.id) === order.guestAccessToken,
|
||||
)
|
||||
|
||||
const summaries = await summarizeOrders(db, authorizedOrders)
|
||||
return summaries.sort(
|
||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,136 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.avatar {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
background: $color-surface;
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatarPulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.avatarInitial {
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.signOut {
|
||||
flex: 1;
|
||||
height: 2.25rem;
|
||||
padding: 0 $space-3;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: $color-background;
|
||||
color: $color-text;
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 50%;
|
||||
background: $color-background;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarInitial {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + #{$space-2});
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 11rem;
|
||||
padding: $space-2;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius;
|
||||
background: $color-background;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.menuLabel {
|
||||
padding: $space-2;
|
||||
border-bottom: 1px solid $color-border;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
width: 100%;
|
||||
padding: $space-2;
|
||||
border: none;
|
||||
border-radius: calc($radius - 2px);
|
||||
background: none;
|
||||
color: $color-text;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $color-surface;
|
||||
}
|
||||
}
|
||||
|
||||
.loginButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: $space-1 0;
|
||||
padding: $space-2;
|
||||
border: none;
|
||||
border-radius: calc($radius - 2px);
|
||||
background: $color-primary;
|
||||
color: $color-primary-contrast;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.signupLink {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $space-1 $space-2;
|
||||
color: $color-text-muted;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
height: 1.1rem;
|
||||
width: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -1,37 +1,151 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
|
||||
import styles from './header-user.module.scss'
|
||||
|
||||
const AccountIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="4" strokeWidth="1.5" />
|
||||
<path d="M4 20c0-4 3.5-7 8-7s8 3 8 7" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const OrdersIcon = () => (
|
||||
<svg
|
||||
className={styles.menuIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 3h6l1 3H8l1-3Z" />
|
||||
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
|
||||
<path d="M9 11h6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const LogOutIcon = () => (
|
||||
<svg
|
||||
className={styles.menuIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17l5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BetterAuthHeader = () => {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (isPending) {
|
||||
return <div className={`${styles.avatar} ${styles.avatarPulse}`} />
|
||||
return <div className={`${styles.trigger} ${styles.pulse}`} />
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
return (
|
||||
<div className={styles.session}>
|
||||
{session.user.image ? (
|
||||
<img src={session.user.image} alt="" className={styles.avatar} />
|
||||
) : (
|
||||
<div className={`${styles.avatar} ${styles.avatarInitial}`}>
|
||||
<span>{session.user.name.charAt(0).toUpperCase() || 'U'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.wrapper} ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={
|
||||
session?.user ? `Account menu for ${session.user.name}` : 'Account'
|
||||
}
|
||||
>
|
||||
{session?.user ? (
|
||||
session.user.image ? (
|
||||
<img src={session.user.image} alt="" className={styles.avatarImage} />
|
||||
) : (
|
||||
<span className={styles.avatarInitial}>
|
||||
{session.user.name.charAt(0).toUpperCase() || 'U'}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<AccountIcon />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={styles.menu} role="menu">
|
||||
{session?.user ? (
|
||||
<>
|
||||
<p className={styles.menuLabel}>{session.user.name}</p>
|
||||
<Link to="/orders" role="menuitem" className={styles.menuButton}>
|
||||
<OrdersIcon />
|
||||
Orders
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.menuButton}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
void authClient.signOut()
|
||||
}}
|
||||
className={styles.signOut}
|
||||
>
|
||||
<LogOutIcon />
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.menuLabel}>Guest</p>
|
||||
<Link to="/orders" role="menuitem" className={styles.menuButton}>
|
||||
<OrdersIcon />
|
||||
Orders
|
||||
</Link>
|
||||
<Link to="/login" role="menuitem" className={styles.loginButton}>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/signup" role="menuitem" className={styles.signupLink}>
|
||||
New here? Sign up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default BetterAuthHeader
|
||||
|
||||
@@ -2,10 +2,30 @@ import { betterAuth } from 'better-auth'
|
||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
// Each provider is only registered once its credentials are configured, so
|
||||
// local dev without OAuth apps set up still works via email/password.
|
||||
export const auth = betterAuth({
|
||||
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
socialProviders: {
|
||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||
? {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET
|
||||
? {
|
||||
apple: {
|
||||
clientId: process.env.APPLE_CLIENT_ID,
|
||||
clientSecret: process.env.APPLE_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
plugins: [tanstackStartCookies()],
|
||||
})
|
||||
|
||||
@@ -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 IndexRouteImport } from './routes/index'
|
||||
import { Route as BasketRouteImport } from './routes/basket'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as SignupRouteImport } from './routes/signup'
|
||||
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
|
||||
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
|
||||
@@ -18,6 +22,26 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BasketRoute = BasketRouteImport.update({
|
||||
id: '/basket',
|
||||
path: '/basket',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OrdersIndexRoute = OrdersIndexRouteImport.update({
|
||||
id: '/orders/',
|
||||
path: '/orders/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
|
||||
id: '/orders/$orderId',
|
||||
path: '/orders/$orderId',
|
||||
@@ -31,31 +55,69 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/orders/': typeof OrdersIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/orders': typeof OrdersIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/orders/': typeof OrdersIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/orders/$orderId' | '/api/auth/$'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/orders/$orderId'
|
||||
| '/orders/'
|
||||
| '/api/auth/$'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/orders/$orderId' | '/api/auth/$'
|
||||
id: '__root__' | '/' | '/orders/$orderId' | '/api/auth/$'
|
||||
to:
|
||||
| '/'
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/orders/$orderId'
|
||||
| '/orders'
|
||||
| '/api/auth/$'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/orders/$orderId'
|
||||
| '/orders/'
|
||||
| '/api/auth/$'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
BasketRoute: typeof BasketRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
||||
OrdersIndexRoute: typeof OrdersIndexRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
}
|
||||
|
||||
@@ -68,6 +130,34 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/basket': {
|
||||
id: '/basket'
|
||||
path: '/basket'
|
||||
fullPath: '/basket'
|
||||
preLoaderRoute: typeof BasketRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/signup': {
|
||||
id: '/signup'
|
||||
path: '/signup'
|
||||
fullPath: '/signup'
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/orders/': {
|
||||
id: '/orders/'
|
||||
path: '/orders'
|
||||
fullPath: '/orders/'
|
||||
preLoaderRoute: typeof OrdersIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/orders/$orderId': {
|
||||
id: '/orders/$orderId'
|
||||
path: '/orders/$orderId'
|
||||
@@ -87,7 +177,11 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
BasketRoute: BasketRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
||||
OrdersIndexRoute: OrdersIndexRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
@@ -7,6 +7,7 @@ export const getRouter = () => {
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultViewTransition: true,
|
||||
})
|
||||
|
||||
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;
|
||||
}
|
||||
+47
-4
@@ -1,16 +1,45 @@
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
HeadContent,
|
||||
Link,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
} from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
|
||||
import '../styles/globals.scss'
|
||||
import { BasketLink } from '#/components/basket/BasketLink.tsx'
|
||||
import { Logo } from '#/components/ui/Logo.tsx'
|
||||
import BetterAuthHeader from '#/integrations/better-auth/header-user.tsx'
|
||||
import { BasketProvider } from '#/lib/basket.tsx'
|
||||
import { getBrand } from '#/server/brand.ts'
|
||||
|
||||
const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
||||
import '../styles/globals.scss'
|
||||
import styles from './__root.module.scss'
|
||||
|
||||
const RootDocument = ({ children }: { children: React.ReactNode }) => {
|
||||
const { brand } = Route.useLoaderData()
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<BasketProvider>
|
||||
<div
|
||||
className={styles.topBar}
|
||||
style={{ viewTransitionName: 'site-header' }}
|
||||
>
|
||||
<Link to="/" className={styles.brand}>
|
||||
<Logo name={brand.name} />
|
||||
</Link>
|
||||
<div className={styles.actions}>
|
||||
<BasketLink />
|
||||
<BetterAuthHeader />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</BasketProvider>
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
@@ -25,9 +54,11 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
loader: async () => ({ brand: await getBrand() }),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
@@ -41,6 +72,18 @@ export const Route = createRootRoute({
|
||||
title: 'Ghost Kitchen',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://fonts.gstatic.com',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap',
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
.page {
|
||||
max-width: 960px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: $space-5 $space-3;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0 0 $space-4;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $space-4;
|
||||
|
||||
@media (min-width: $breakpoint-tablet) {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
align-self: start;
|
||||
padding: $space-3;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
+5
-51
@@ -1,69 +1,24 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
||||
import { MenuList } from '#/components/ordering/MenuList.tsx'
|
||||
import { OrderSummary } from '#/components/ordering/OrderSummary.tsx'
|
||||
import { useBasket } from '#/lib/basket.tsx'
|
||||
import { getBrand } from '#/server/brand.ts'
|
||||
import { getMenu } from '#/server/menu.ts'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
|
||||
const OrderingPage = () => {
|
||||
const { brand, menu } = Route.useLoaderData()
|
||||
const navigate = useNavigate()
|
||||
const [quantities, setQuantities] = useState<Record<number, number>>({})
|
||||
|
||||
const add = (menuItemId: number) =>
|
||||
setQuantities((prev) => ({
|
||||
...prev,
|
||||
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
|
||||
}))
|
||||
|
||||
const remove = (menuItemId: number) =>
|
||||
setQuantities((prev) => {
|
||||
const current = prev[menuItemId] ?? 0
|
||||
if (current <= 1) {
|
||||
const next = { ...prev }
|
||||
delete next[menuItemId]
|
||||
return next
|
||||
}
|
||||
return { ...prev, [menuItemId]: current - 1 }
|
||||
})
|
||||
|
||||
const items = Object.entries(quantities).flatMap(([menuItemId, quantity]) =>
|
||||
Array.from({ length: quantity }, () => ({
|
||||
menuItemId: Number(menuItemId),
|
||||
})),
|
||||
)
|
||||
const { menu } = Route.useLoaderData()
|
||||
const { quantities, add, remove } = useBasket()
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1 className={styles.heading}>{brand.name}</h1>
|
||||
<div className={styles.layout}>
|
||||
<MenuList
|
||||
menu={menu}
|
||||
quantities={quantities}
|
||||
onAdd={add}
|
||||
onRemove={remove}
|
||||
/>
|
||||
<aside className={styles.sidebar}>
|
||||
<OrderSummary menu={menu} quantities={quantities} />
|
||||
<CheckoutSection
|
||||
items={items}
|
||||
disabled={items.length === 0}
|
||||
onPlaced={(order) =>
|
||||
navigate({
|
||||
to: '/orders/$orderId',
|
||||
params: { orderId: String(order.id) },
|
||||
search: order.guestAccessToken
|
||||
? { token: order.guestAccessToken }
|
||||
: {},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -71,8 +26,7 @@ const OrderingPage = () => {
|
||||
export const Route = createFileRoute('/')({
|
||||
loader: async () => {
|
||||
const brand = await getBrand()
|
||||
const menu = await getMenu({ data: { brandId: brand.id } })
|
||||
return { brand, menu }
|
||||
return { menu: await getMenu({ data: { brandId: brand.id } }) }
|
||||
},
|
||||
component: OrderingPage,
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
export const getMyOrders = createServerFn({ method: 'GET' }).handler(
|
||||
async () => {
|
||||
const customerId = await getSessionCustomerId()
|
||||
if (!customerId) {
|
||||
throw new Error('You must be signed in to view your orders')
|
||||
}
|
||||
return ordering.getOrdersForCustomer(db, customerId)
|
||||
},
|
||||
)
|
||||
|
||||
export const getGuestOrders = createServerFn({ method: 'GET' })
|
||||
.validator(
|
||||
z.object({
|
||||
refs: z.array(
|
||||
z.object({ orderId: z.number(), guestAccessToken: z.string() }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ data }) =>
|
||||
ordering.getGuestOrderSummaries(db, data.refs),
|
||||
)
|
||||
|
||||
export const cancelOrderItem = createServerFn({ method: 'POST' })
|
||||
.validator(accessSchema.extend({ orderItemId: z.number() }))
|
||||
.handler(async ({ data }) =>
|
||||
|
||||
@@ -21,3 +21,80 @@ body {
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
// Route navigation transitions, powered by the router's defaultViewTransition
|
||||
// (see router.tsx). No-ops in browsers without View Transitions support.
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.22s;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation-name: view-transition-fade-out;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: view-transition-fade-in;
|
||||
}
|
||||
|
||||
// Any element that carries a matching `view-transition-name` on both the
|
||||
// outgoing and incoming page (e.g. an Order card morphing into its detail
|
||||
// header, see OrdersListView / OrderStatusView) gets its own transition
|
||||
// group — this smooths and slows the browser's default morph/resize
|
||||
// animation for those to match the root crossfade's pace instead of the
|
||||
// UA default's snappier one.
|
||||
::view-transition-group(*) {
|
||||
animation-duration: 0.32s;
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
// The top nav bar (see __root.tsx) carries its own `view-transition-name`
|
||||
// so it's excluded from the root snapshot above — otherwise the root
|
||||
// fade's translateY drags the persistent header along with it, making it
|
||||
// visibly jump on every navigation. It doesn't change between routes, so
|
||||
// it should just stay put: no morph, no crossfade.
|
||||
::view-transition-group(site-header) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-old(site-header),
|
||||
::view-transition-new(site-header) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// document.startViewTransition() itself doesn't check this preference —
|
||||
// without an explicit override every browser falls back to its own default
|
||||
// crossfade/morph animations, unanimated only for the rules above (which
|
||||
// only apply under no-preference). Kill all of it outright here instead.
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes view-transition-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes view-transition-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
const placeAccountOrder = (
|
||||
items: Array<{ menuItemId: number }>,
|
||||
customerId: string,
|
||||
) =>
|
||||
ordering.placeOrder(testDb.db, payment, {
|
||||
items,
|
||||
customer: { type: 'account', customerId },
|
||||
})
|
||||
|
||||
// Tests act as the guest who placed the order, so they present its access
|
||||
// token exactly like a real caller would — this exercises the same
|
||||
// ownership check a stranger guessing the order id would fail.
|
||||
@@ -67,13 +76,58 @@ describe('menu', () => {
|
||||
|
||||
const pizza = menu.find((item) => item.name === 'Pizza')
|
||||
expect(pizza?.steps).toEqual([
|
||||
{ sequence: 1, machineTag: 'oven', estimatedDurationSeconds: 600 },
|
||||
{
|
||||
sequence: 1,
|
||||
name: 'Flattening dough',
|
||||
machineTag: 'prep',
|
||||
estimatedDurationSeconds: 30,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
name: 'Spreading sauce',
|
||||
machineTag: 'prep',
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
{
|
||||
sequence: 3,
|
||||
name: 'Adding toppings',
|
||||
machineTag: 'prep',
|
||||
estimatedDurationSeconds: 30,
|
||||
},
|
||||
{
|
||||
sequence: 4,
|
||||
name: 'Baking in the oven',
|
||||
machineTag: 'oven',
|
||||
estimatedDurationSeconds: 600,
|
||||
},
|
||||
{
|
||||
sequence: 5,
|
||||
name: 'Slicing',
|
||||
machineTag: 'assembly',
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
{
|
||||
sequence: 6,
|
||||
name: 'Packaging',
|
||||
machineTag: 'assembly',
|
||||
estimatedDurationSeconds: 20,
|
||||
},
|
||||
])
|
||||
|
||||
const burger = menu.find((item) => item.name === 'Burger')
|
||||
expect(burger?.steps).toEqual([
|
||||
{ sequence: 1, machineTag: 'grill', estimatedDurationSeconds: 240 },
|
||||
{ sequence: 2, machineTag: 'assembly', estimatedDurationSeconds: 90 },
|
||||
{
|
||||
sequence: 1,
|
||||
name: 'Grilling patty',
|
||||
machineTag: 'grill',
|
||||
estimatedDurationSeconds: 240,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
name: 'Assembling burger',
|
||||
machineTag: 'assembly',
|
||||
estimatedDurationSeconds: 90,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -98,8 +152,8 @@ describe('placing an order', () => {
|
||||
const pizzaItem = status.items.find(
|
||||
(item) => item.menuItemName === 'Pizza',
|
||||
)!
|
||||
// Only one oven, but the pizza is the only thing waiting for it, so it
|
||||
// starts immediately.
|
||||
// Only one prep station, but the pizza is the only thing waiting for
|
||||
// it, so its first Step starts immediately.
|
||||
expect(pizzaItem.steps[0].status).toBe('in_progress')
|
||||
expect(pizzaItem.cancellable).toBe(false)
|
||||
|
||||
@@ -192,7 +246,7 @@ describe('placing an order', () => {
|
||||
|
||||
describe('cancelling an Order Item', () => {
|
||||
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
@@ -205,7 +259,7 @@ describe('cancelling an Order Item', () => {
|
||||
const burgerItem = before.items.find(
|
||||
(item) => item.menuItemName === 'Burger',
|
||||
)!
|
||||
expect(pizzaItem.status).toBe('paid') // still queued, oven is busy
|
||||
expect(pizzaItem.status).toBe('paid') // still queued, prep station is busy
|
||||
expect(burgerItem.steps[0].status).toBe('in_progress') // grill was free
|
||||
|
||||
await ordering.cancelOrderItem(
|
||||
@@ -231,7 +285,7 @@ describe('cancelling an Order Item', () => {
|
||||
})
|
||||
|
||||
it('completes the order once its only item is cancelled', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
@@ -279,7 +333,7 @@ describe('cancelling an Order Item', () => {
|
||||
})
|
||||
|
||||
it('recharges the customer if the DB transaction fails after the refund has already gone through', async () => {
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
@@ -319,7 +373,7 @@ describe('cancelling an Order Item', () => {
|
||||
it('surfaces the original cancellation failure even if the compensating re-charge also fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
|
||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
])
|
||||
@@ -417,8 +471,13 @@ describe('advancing steps and pickup', () => {
|
||||
|
||||
await ordering.advanceOrderItemStep(testDb.db, firstItemId)
|
||||
|
||||
// The freed prep station goes to whichever queued Step has been
|
||||
// waiting longest for that Tag — the second order's first Step, which
|
||||
// was already queued, rather than the first order's own next Step,
|
||||
// which only just joined the queue.
|
||||
firstStatus = await getStatus(firstOrder)
|
||||
expect(firstStatus.items[0].status).toBe('ready')
|
||||
expect(firstStatus.items[0].steps[0].status).toBe('done')
|
||||
expect(firstStatus.items[0].steps[1].status).toBe('queued')
|
||||
|
||||
secondStatus = await getStatus(secondOrder)
|
||||
expect(secondStatus.items[0].steps[0].status).toBe('in_progress')
|
||||
@@ -498,3 +557,64 @@ describe('access control', () => {
|
||||
).rejects.toThrow(ordering.ForbiddenError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('order history', () => {
|
||||
it("lists a signed-in customer's own Orders, most recent first, with per-item quantities, total, and no guest access token", async () => {
|
||||
await placeAccountOrder(
|
||||
[{ menuItemId: seed.menuItems.burger.id }],
|
||||
'test-customer-1',
|
||||
)
|
||||
await placeAccountOrder(
|
||||
[
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
{ menuItemId: seed.menuItems.pizza.id },
|
||||
],
|
||||
'test-customer-1',
|
||||
)
|
||||
|
||||
const orders = await ordering.getOrdersForCustomer(
|
||||
testDb.db,
|
||||
'test-customer-1',
|
||||
)
|
||||
|
||||
expect(orders).toHaveLength(2)
|
||||
expect(orders[0].items).toEqual([{ menuItemName: 'Pizza', quantity: 2 }])
|
||||
expect(orders[0].totalCents).toBe(seed.menuItems.pizza.priceCents * 2)
|
||||
expect(orders[0].guestAccessToken).toBeNull()
|
||||
expect(orders[1].items).toEqual([{ menuItemName: 'Burger', quantity: 1 }])
|
||||
})
|
||||
|
||||
it("does not include another customer's Orders", async () => {
|
||||
await placeAccountOrder(
|
||||
[{ menuItemId: seed.menuItems.burger.id }],
|
||||
'someone-else',
|
||||
)
|
||||
|
||||
const orders = await ordering.getOrdersForCustomer(
|
||||
testDb.db,
|
||||
'test-customer-1',
|
||||
)
|
||||
expect(orders).toEqual([])
|
||||
})
|
||||
|
||||
it("looks up a guest's Orders by their remembered (orderId, token) pairs, skipping any with the wrong token", async () => {
|
||||
const order = await placeGuestOrder([
|
||||
{ menuItemId: seed.menuItems.burger.id },
|
||||
])
|
||||
|
||||
const withCorrectToken = await ordering.getGuestOrderSummaries(
|
||||
testDb.db,
|
||||
[{ orderId: order.id, guestAccessToken: order.guestAccessToken! }],
|
||||
)
|
||||
expect(withCorrectToken).toHaveLength(1)
|
||||
expect(withCorrectToken[0].guestAccessToken).toBe(order.guestAccessToken)
|
||||
expect(withCorrectToken[0].items).toEqual([
|
||||
{ menuItemName: 'Burger', quantity: 1 },
|
||||
])
|
||||
|
||||
const withWrongToken = await ordering.getGuestOrderSummaries(testDb.db, [
|
||||
{ orderId: order.id, guestAccessToken: 'not-the-real-token' },
|
||||
])
|
||||
expect(withWrongToken).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,10 @@ import viteReact from '@vitejs/plugin-react'
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
// Fail loudly if port 3000 is taken instead of silently picking another
|
||||
// port — a silent port change breaks Better Auth's origin check, since
|
||||
// BETTER_AUTH_URL is pinned to http://localhost:3000.
|
||||
server: { strictPort: true },
|
||||
plugins: [devtools(), tanstackStart(), viteReact()],
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user