diff --git a/.claude/skills/run-ghost-kitchen/SKILL.md b/.claude/skills/run-ghost-kitchen/SKILL.md new file mode 100644 index 0000000..d3e83be --- /dev/null +++ b/.claude/skills/run-ghost-kitchen/SKILL.md @@ -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: } +computer { action: "screenshot", tabId: } +``` + +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: } +computer { action: "left_click", coordinate: [,], tabId: } + +# Basket icon (top-right) -> /basket shows line items + total +computer { action: "left_click", coordinate: [1338, 29], tabId: } # icon position at 1400px width + +# Sign up (/signup): Name, Email, Password fields -> "Create account" +navigate { url: "http://localhost:3000/signup", tabId: } +# 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. diff --git a/.env.example b/.env.example index 152f9a5..39d25b5 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 61eabf7..5fa002d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/drizzle/0003_soft_psynapse.sql b/drizzle/0003_soft_psynapse.sql new file mode 100644 index 0000000..d9e5732 --- /dev/null +++ b/drizzle/0003_soft_psynapse.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..d9153c0 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6145a92..05f2390 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1788976393301, "tag": "0002_large_lake", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1789028563859, + "tag": "0003_soft_psynapse", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fb6979c..308a11c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9aabbfb..c00a514 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/setup-google-sso.sh b/scripts/setup-google-sso.sh new file mode 100755 index 0000000..4625947 --- /dev/null +++ b/scripts/setup-google-sso.sh @@ -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." diff --git a/src/components/auth/AuthCard.module.scss b/src/components/auth/AuthCard.module.scss new file mode 100644 index 0000000..34ffe8f --- /dev/null +++ b/src/components/auth/AuthCard.module.scss @@ -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; + } +} diff --git a/src/components/auth/AuthCard.tsx b/src/components/auth/AuthCard.tsx new file mode 100644 index 0000000..a067cbc --- /dev/null +++ b/src/components/auth/AuthCard.tsx @@ -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) => ( +
+
+

{title}

+ {children} +

{footer}

+
+
+) diff --git a/src/components/auth/SsoButtons.module.scss b/src/components/auth/SsoButtons.module.scss new file mode 100644 index 0000000..5e3d8b9 --- /dev/null +++ b/src/components/auth/SsoButtons.module.scss @@ -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; +} diff --git a/src/components/auth/SsoButtons.tsx b/src/components/auth/SsoButtons.tsx new file mode 100644 index 0000000..0ed5e84 --- /dev/null +++ b/src/components/auth/SsoButtons.tsx @@ -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 = () => ( + +) + +const AppleIcon = () => ( + +) + +export const SsoButtons = ({ onError }: SsoButtonsProps) => ( +
+ + +
+) diff --git a/src/components/basket/BasketLink.module.scss b/src/components/basket/BasketLink.module.scss new file mode 100644 index 0000000..c09453b --- /dev/null +++ b/src/components/basket/BasketLink.module.scss @@ -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; +} diff --git a/src/components/basket/BasketLink.tsx b/src/components/basket/BasketLink.tsx new file mode 100644 index 0000000..086a253 --- /dev/null +++ b/src/components/basket/BasketLink.tsx @@ -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 = () => ( + +) + +// 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 ( + + {itemCount > 0 && !onBasketPage && ( + + + {itemCount} + + )} + + ) +} diff --git a/src/components/ordering/CheckoutSection.module.scss b/src/components/ordering/CheckoutSection.module.scss index 8781ef5..cf295af 100644 --- a/src/components/ordering/CheckoutSection.module.scss +++ b/src/components/ordering/CheckoutSection.module.scss @@ -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; + } +} diff --git a/src/components/ordering/CheckoutSection.tsx b/src/components/ordering/CheckoutSection.tsx index 46e7c7c..463eff2 100644 --- a/src/components/ordering/CheckoutSection.tsx +++ b/src/components/ordering/CheckoutSection.tsx @@ -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( + null, + ) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(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 ( +
+ + + {error &&

{error}

} +
+ ) + } + return (
- quantities: Record -} - -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 ( -

- Your order is empty — add something from the menu. -

- ) - } - - const totalCents = lines.reduce( - (sum, { item, quantity }) => sum + item.priceCents * quantity, - 0, - ) - - return ( -
-
    - {lines.map(({ item, quantity }) => ( -
  • - - {quantity} × {item.name} - - ${((item.priceCents * quantity) / 100).toFixed(2)} -
  • - ))} -
-
- Total - ${(totalCents / 100).toFixed(2)} -
-
- ) -} diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss index 87aba2d..0552686 100644 --- a/src/components/orders/OrderStatusView.module.scss +++ b/src/components/orders/OrderStatusView.module.scss @@ -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; + } + } } diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index e1842f6..7ce967f 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -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 = () => ( + +) + export type OrderStatusResult = { id: number status: OrderStatus @@ -41,8 +66,14 @@ export const OrderStatusView = ({ return (
-

Order #{status.id}

-

Status: {status.status}

+ + + Back to orders + +
+

Order #{status.id}

+

Status: {status.status}

+
{error &&

{error}

}
    {status.items.map((item) => ( @@ -51,41 +82,43 @@ export const OrderStatusView = ({ {item.menuItemName} {item.status} -
      +
        {item.steps.map((step) => (
      1. - {step.machineTag} - {step.status === 'queued' && - step.queuePosition !== null && - ` — #${step.queuePosition} in line`} +
      2. ))}
      {item.etaSeconds !== null && ( -

      - Ready in about {Math.ceil(item.etaSeconds / 60)} min -

      +

      {formatEtaMessage(item.etaSeconds)}

      )}
      - {item.cancellable && ( - - )} + onClick={() => { + // + }} + > + Buy again + + + {item.status === 'ready' && ( + )}
      ))} diff --git a/src/components/orders/OrdersListView.module.scss b/src/components/orders/OrdersListView.module.scss new file mode 100644 index 0000000..55690b6 --- /dev/null +++ b/src/components/orders/OrdersListView.module.scss @@ -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; +} diff --git a/src/components/orders/OrdersListView.tsx b/src/components/orders/OrdersListView.tsx new file mode 100644 index 0000000..cf6bc6a --- /dev/null +++ b/src/components/orders/OrdersListView.tsx @@ -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 +} + +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) => ( +
      +

      Your orders

      + {orders.length === 0 ? ( +

      + You haven't placed an order yet — browse the menu{' '} + to get started. +

      + ) : ( +
        + {orders.map((order) => ( +
      • + +
        + Order #{order.id} + {order.status} +
        +

        {formatItems(order.items)}

        +

        + {order.createdAt.toLocaleDateString([], { + month: 'short', + day: 'numeric', + })}{' '} + · {formatPrice(order.totalCents)} +

        + +
      • + ))} +
      + )} +
      +) diff --git a/src/components/ui/Logo.module.scss b/src/components/ui/Logo.module.scss new file mode 100644 index 0000000..aa7669d --- /dev/null +++ b/src/components/ui/Logo.module.scss @@ -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; +} diff --git a/src/components/ui/Logo.tsx b/src/components/ui/Logo.tsx new file mode 100644 index 0000000..08d61c9 --- /dev/null +++ b/src/components/ui/Logo.tsx @@ -0,0 +1,60 @@ +import { useId } from 'react' + +import styles from './Logo.module.scss' + +type LogoProps = { + name: string +} + +const GhostIcon = () => { + const eyeMaskId = useId() + + return ( + + ) +} + +// 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 {name} + + const oIndex = first.toLowerCase().indexOf('o') + + return ( + + + {oIndex === -1 ? ( + first + ) : ( + <> + {first.slice(0, oIndex)} + + {first.slice(oIndex + 1)} + + )} + {' '} + {restText} + + ) +} diff --git a/src/db/schema.ts b/src/db/schema.ts index dbc62f0..289a6e5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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), diff --git a/src/db/seed.ts b/src/db/seed.ts index 95e6009..70dbe2c 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -11,6 +11,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase) => { .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) => { .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) => { }) .returning() - await db.insert(schema.menuItemSteps).values({ - menuItemId: pizza.id, - sequence: 1, - machineTagId: ovenTag.id, - estimatedDurationSeconds: 600, - }) + 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) => { { 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) => { 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 }, } } diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index cffac64..6290f78 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -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, +): Promise> => { + 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() + 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> => { + 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, +): Promise> => { + 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(), + ) +} diff --git a/src/integrations/better-auth/header-user.module.scss b/src/integrations/better-auth/header-user.module.scss index cec48d9..89704fa 100644 --- a/src/integrations/better-auth/header-user.module.scss +++ b/src/integrations/better-auth/header-user.module.scss @@ -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 { diff --git a/src/integrations/better-auth/header-user.tsx b/src/integrations/better-auth/header-user.tsx index 129a147..2d8f8c8 100644 --- a/src/integrations/better-auth/header-user.tsx +++ b/src/integrations/better-auth/header-user.tsx @@ -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 = () => ( + +) + +const OrdersIcon = () => ( + +) + +const LogOutIcon = () => ( + +) + const BetterAuthHeader = () => { const { data: session, isPending } = authClient.useSession() + const [open, setOpen] = useState(false) + const wrapperRef = useRef(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
      + return
      } - if (session?.user) { - return ( -
      - {session.user.image ? ( - + return ( +
      + -
      - ) - } + - return null + {open && ( +
      + {session?.user ? ( + <> +

      {session.user.name}

      + + + Orders + + + + ) : ( + <> +

      Guest

      + + + Orders + + + Sign in + + + New here? Sign up + + + )} +
      + )} +
      + ) } export default BetterAuthHeader diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a85d2e5..f80bff4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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()], }) diff --git a/src/lib/basket.tsx b/src/lib/basket.tsx new file mode 100644 index 0000000..93b57df --- /dev/null +++ b/src/lib/basket.tsx @@ -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 + itemCount: number + add: (menuItemId: number) => void + remove: (menuItemId: number) => void + clear: () => void +} + +const BasketContext = createContext(null) + +const readStoredQuantities = (): Record => { + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +export const BasketProvider = ({ children }: { children: ReactNode }) => { + const [quantities, setQuantities] = useState>({}) + + // 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 ( + {children} + ) +} + +export const useBasket = () => { + const context = useContext(BasketContext) + if (!context) { + throw new Error('useBasket must be used within a BasketProvider') + } + return context +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..7107d3c --- /dev/null +++ b/src/lib/format.ts @@ -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)}` diff --git a/src/lib/guestIdentity.ts b/src/lib/guestIdentity.ts new file mode 100644 index 0000000..34e1147 --- /dev/null +++ b/src/lib/guestIdentity.ts @@ -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. + } +} diff --git a/src/lib/guestOrders.ts b/src/lib/guestOrders.ts new file mode 100644 index 0000000..99c0f05 --- /dev/null +++ b/src/lib/guestOrders.ts @@ -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 => { + 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. + } +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8d48e3a..adb42ad 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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 diff --git a/src/router.tsx b/src/router.tsx index f8e3005..bc49901 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -7,6 +7,7 @@ export const getRouter = () => { scrollRestoration: true, defaultPreload: 'intent', defaultPreloadStaleTime: 0, + defaultViewTransition: true, }) return router diff --git a/src/routes/__root.module.scss b/src/routes/__root.module.scss new file mode 100644 index 0000000..114001b --- /dev/null +++ b/src/routes/__root.module.scss @@ -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; +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index dacf51d..869bd20 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,33 +1,64 @@ -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 }) => ( - - - - - - {children} - , - }, - ]} - /> - - - -) +import '../styles/globals.scss' +import styles from './__root.module.scss' + +const RootDocument = ({ children }: { children: React.ReactNode }) => { + const { brand } = Route.useLoaderData() + + return ( + + + + + + +
      + + + +
      + + +
      +
      + {children} +
      + , + }, + ]} + /> + + + + ) +} 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, }) diff --git a/src/routes/basket.module.scss b/src/routes/basket.module.scss new file mode 100644 index 0000000..582eabb --- /dev/null +++ b/src/routes/basket.module.scss @@ -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; +} diff --git a/src/routes/basket.tsx b/src/routes/basket.tsx new file mode 100644 index 0000000..7d9e883 --- /dev/null +++ b/src/routes/basket.tsx @@ -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 ( +
      +

      Your basket

      + + {lines.length === 0 ? ( +

      + Your basket is empty — browse the menu to add + something. +

      + ) : ( + <> +
        + {lines.map(({ item, quantity }) => ( +
      • +
        + {item.name} + + ${((item.priceCents * quantity) / 100).toFixed(2)} + +
        +
        + + {quantity} + +
        +
      • + ))} +
      + +
      + Total + ${(totalCents / 100).toFixed(2)} +
      + + { + await navigate({ + to: '/orders/$orderId', + params: { orderId: String(order.id) }, + search: order.guestAccessToken + ? { token: order.guestAccessToken } + : {}, + }) + clear() + }} + /> + + )} +
      + ) +} + +export const Route = createFileRoute('/basket')({ + loader: async () => { + const brand = await getBrand() + const menu = await getMenu({ data: { brandId: brand.id } }) + return { menu } + }, + component: BasketPage, +}) diff --git a/src/routes/index.module.scss b/src/routes/index.module.scss index cd148ce..768c71a 100644 --- a/src/routes/index.module.scss +++ b/src/routes/index.module.scss @@ -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; -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index c8cd6b1..fcf265b 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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>({}) - - 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 (
      -

      {brand.name}

      -
      - - -
      +
      ) } @@ -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, }) diff --git a/src/routes/login.module.scss b/src/routes/login.module.scss new file mode 100644 index 0000000..1326839 --- /dev/null +++ b/src/routes/login.module.scss @@ -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; +} diff --git a/src/routes/login.tsx b/src/routes/login.tsx new file mode 100644 index 0000000..e2f2b59 --- /dev/null +++ b/src/routes/login.tsx @@ -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(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 ( + + Don't have an account? Sign up + + } + > + + + + + {error &&

      {error}

      } + +
      or
      + +
      + ) +} + +export const Route = createFileRoute('/login')({ + component: LoginPage, +}) diff --git a/src/routes/orders/index.tsx b/src/routes/orders/index.tsx new file mode 100644 index 0000000..3d9d2bf --- /dev/null +++ b/src/routes/orders/index.tsx @@ -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 | 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 +} + +export const Route = createFileRoute('/orders/')({ + loader: () => getMyOrders().catch(() => null), + component: OrdersRoute, +}) diff --git a/src/routes/signup.module.scss b/src/routes/signup.module.scss new file mode 100644 index 0000000..1326839 --- /dev/null +++ b/src/routes/signup.module.scss @@ -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; +} diff --git a/src/routes/signup.tsx b/src/routes/signup.tsx new file mode 100644 index 0000000..7e58ac7 --- /dev/null +++ b/src/routes/signup.tsx @@ -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(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 ( + + Already have an account? Sign in + + } + > +
      + + + + + {error &&

      {error}

      } +
      +
      or
      + +
      + ) +} + +export const Route = createFileRoute('/signup')({ + component: SignupPage, +}) diff --git a/src/server/orders.ts b/src/server/orders.ts index 09caade..3e0f109 100644 --- a/src/server/orders.ts +++ b/src/server/orders.ts @@ -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 }) => diff --git a/src/styles/globals.scss b/src/styles/globals.scss index e5aaceb..5e75408 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -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); + } +} diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..90eac65 --- /dev/null +++ b/tests/format.test.ts @@ -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') + }) +}) diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts index 4e4999a..f1820f5 100644 --- a/tests/ordering.test.ts +++ b/tests/ordering.test.ts @@ -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([]) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index ffd0336..4a6e8ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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()], })