Compare commits
57
Commits
456c3acda5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
757cf40358 | ||
|
|
6fc315a5f2 | ||
|
|
440f9e5696 | ||
|
|
d68806aaf8 | ||
|
|
3efde72524 | ||
|
|
edd34a00a7 | ||
|
|
ac2aa770fb | ||
|
|
d987c71010 | ||
|
|
1dae53f90a | ||
|
|
7cf286850c | ||
|
|
a6ac32d783 | ||
|
|
c124b0415a | ||
|
|
815939cbc6 | ||
|
|
7f72baf97d | ||
|
|
8481032e30 | ||
|
|
917fc275f9 | ||
|
|
a8e655a810 | ||
|
|
7f3728b5bf | ||
|
|
3d736769e0 | ||
|
|
b0ef917cfc | ||
|
|
05a91bb5c3 | ||
|
|
0da1f47573 | ||
|
|
de158d7c36 | ||
|
|
04a0e4a708 | ||
|
|
5bc57f0200 | ||
|
|
c539ef84cd | ||
|
|
aa8d40bc4e | ||
|
|
6a81b305bc | ||
|
|
6bd63b53c6 | ||
|
|
8653418778 | ||
|
|
2f37b6bba1 | ||
|
|
315491dd5b | ||
|
|
c5ed038bca | ||
|
|
f4f3c26e1e | ||
|
|
5f375a0966 | ||
|
|
8d7f99848c | ||
|
|
f819b0cd46 | ||
|
|
a2e97c293d | ||
|
|
86d1a32dd8 | ||
|
|
370e77f1a6 | ||
|
|
a716080f7d | ||
|
|
439932c168 | ||
|
|
c8d1721358 | ||
|
|
18a7d2030e | ||
|
|
927f6ff56e | ||
|
|
d66e09f515 | ||
|
|
e98b0d86d2 | ||
|
|
79bd64ce8c | ||
|
|
5059f40d4c | ||
|
|
cacfce8eae | ||
|
|
0d8f8f8148 | ||
|
|
ff8fc388c0 | ||
|
|
ea459e72b3 | ||
|
|
b7fdbd4779 | ||
|
|
506319ce4f | ||
|
|
9c3ad51686 | ||
|
|
d291312139 |
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
name: run-ghost-kitchen
|
||||||
|
description: Build, run, and drive Ghost Kitchen (TanStack Start ordering app). Use when asked to start the app, run its tests, seed its database, take a screenshot of its UI, or interact with the running app (menu, basket, sign-in/sign-up).
|
||||||
|
---
|
||||||
|
|
||||||
|
Ghost Kitchen is a TanStack Start web app (Vite dev server on port 3000,
|
||||||
|
Postgres via Docker, Better Auth). There is no `chromium-cli` binary in
|
||||||
|
this environment — drive it with the `mcp__claude-in-chrome__*` tools
|
||||||
|
(load them via `ToolSearch` if deferred; see the `claude-in-chrome`
|
||||||
|
skill). All paths below are relative to the repo root.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Docker (for Postgres) and Node — both already present in this
|
||||||
|
environment. No OS packages were needed.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
One-time, per fresh checkout / fresh Postgres volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # starts Postgres on localhost:5433
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
`BETTER_AUTH_SECRET` in `.env.example` ships empty — Better Auth needs a
|
||||||
|
real value or auth routes fail. Generate one and fill it in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
# paste the output as BETTER_AUTH_SECRET= in .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Then apply migrations and seed data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate # this project's Drizzle migrations
|
||||||
|
npm run auth:migrate # Better Auth's own tables (users, sessions, ...)
|
||||||
|
npm run db:seed # sample Brand, menu, Machines — SKIP if DB already seeded, see Gotchas
|
||||||
|
```
|
||||||
|
|
||||||
|
Against a genuinely empty database, `auth:migrate` prompts interactively
|
||||||
|
("Are you sure you want to run these migrations?"). Non-interactively,
|
||||||
|
answer it with `yes | npm run auth:migrate`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
No separate build step for local dev (Vite serves TS directly). For a
|
||||||
|
production bundle: `npm run build`.
|
||||||
|
|
||||||
|
## Run (agent path)
|
||||||
|
|
||||||
|
Start the dev server in the background and wait for it to actually serve
|
||||||
|
(don't `sleep` — poll the port):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev &
|
||||||
|
timeout 30 bash -c 'until curl -sf http://localhost:3000 >/dev/null; do sleep 1; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop it with `lsof -ti:3000 -sTCP:LISTEN | xargs -r kill` before
|
||||||
|
relaunching, or the next run hits `EADDRINUSE`. Check for an
|
||||||
|
already-running instance first (`lsof -i :3000 -sTCP:LISTEN`) — in this
|
||||||
|
project's dev container a server is often already up from a prior
|
||||||
|
session; reuse it instead of starting a duplicate.
|
||||||
|
|
||||||
|
Drive it with the `mcp__claude-in-chrome__*` tools:
|
||||||
|
|
||||||
|
```
|
||||||
|
tabs_context_mcp { createIfEmpty: true }
|
||||||
|
navigate { url: "http://localhost:3000", tabId: <tab> }
|
||||||
|
computer { action: "screenshot", tabId: <tab> }
|
||||||
|
```
|
||||||
|
|
||||||
|
One representative flow that proves the app works end-to-end (menu ->
|
||||||
|
basket -> sign-up):
|
||||||
|
|
||||||
|
```
|
||||||
|
# Menu page: bump a quantity stepper
|
||||||
|
find { query: "increment button for Pizza", tabId: <tab> }
|
||||||
|
computer { action: "left_click", coordinate: [<x>,<y>], tabId: <tab> }
|
||||||
|
|
||||||
|
# Basket icon (top-right) -> /basket shows line items + total
|
||||||
|
computer { action: "left_click", coordinate: [1338, 29], tabId: <tab> } # icon position at 1400px width
|
||||||
|
|
||||||
|
# Sign up (/signup): Name, Email, Password fields -> "Create account"
|
||||||
|
navigate { url: "http://localhost:3000/signup", tabId: <tab> }
|
||||||
|
# fill Name/Email/Password via computer left_click + type, then click "Create account"
|
||||||
|
```
|
||||||
|
|
||||||
|
Close tabs you created when done (`tabs_close_mcp`).
|
||||||
|
|
||||||
|
## Run (human path)
|
||||||
|
|
||||||
|
`npm run dev` -> serves on http://localhost:3000, Ctrl-C to stop. Useless
|
||||||
|
headless without a browser to point at it.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # vitest run — 27 tests, 2 files, ~3s
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **`.env.local` doesn't exist by default** and isn't created by any
|
||||||
|
script — `cp .env.example .env.local` is a required manual step, and
|
||||||
|
`BETTER_AUTH_SECRET` inside it ships blank. Leaving it blank breaks
|
||||||
|
sign-in/sign-up silently at the Better Auth layer.
|
||||||
|
- **`npm run db:seed` is not idempotent.** Re-running it against an
|
||||||
|
already-seeded DB throws `DrizzleQueryError: duplicate key value
|
||||||
|
violates unique constraint "machine_tags_name_idx"` and exits 1. If
|
||||||
|
Postgres already has data (e.g. a long-lived dev container), skip
|
||||||
|
seeding rather than treating the error as a setup failure.
|
||||||
|
- **Port 3000 is often already occupied** by a dev server started
|
||||||
|
outside the current session (e.g. the user's own terminal). Check
|
||||||
|
`lsof -i :3000 -sTCP:LISTEN` before launching — if something already
|
||||||
|
answers `curl -sf http://localhost:3000`, drive that instead of
|
||||||
|
fighting over the port.
|
||||||
|
- **Session/basket state persists across browser tab reloads** (basket
|
||||||
|
contents and signed-in user both survived a fresh tab + navigate in
|
||||||
|
testing) — a new tab hitting `localhost:3000` may already show a
|
||||||
|
logged-in user or a non-empty basket from a prior run in the same
|
||||||
|
container. Not a bug; just don't assume a fresh tab means fresh state.
|
||||||
|
- **`[TanStack Devtools] Failed to load: TypeError: Failed to fetch
|
||||||
|
dynamically imported module ...`** shows up in the console on first
|
||||||
|
load. It's a stale Vite dep-cache issue with the devtools panel, not
|
||||||
|
an app error — harmless, ignore it (verified via
|
||||||
|
`read_console_messages`, the rest of the app functions normally).
|
||||||
|
- **Adding a `NOT NULL` column via `db:generate` against a populated
|
||||||
|
local DB** makes `drizzle-kit generate` prompt interactively for a
|
||||||
|
default value — awkward to script. Reset the Postgres volume first
|
||||||
|
(`docker compose down -v && docker compose up -d`, then recreate the
|
||||||
|
DB, migrate, `yes | npm run auth:migrate`, reseed) so the column is
|
||||||
|
added to an empty table and no prompt appears.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`Error: Port 3000 is already in use` on `npm run dev`**: another
|
||||||
|
process already holds it — see the port gotcha above. Either reuse it
|
||||||
|
(`curl` returns 200) or `lsof -ti:3000 -sTCP:LISTEN | xargs -r kill`
|
||||||
|
first if you own that process.
|
||||||
|
- **`duplicate key value violates unique constraint` from `db:seed`**:
|
||||||
|
DB already seeded — skip the seed step, don't debug it.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"projectName": "ghost-kitchen-scaffold",
|
"projectName": "ghost-kitchen-scaffold",
|
||||||
"mode": "file-router",
|
"mode": "file-router",
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"tailwind": true,
|
"tailwind": false,
|
||||||
"packageManager": "npm",
|
"packageManager": "npm",
|
||||||
"git": false,
|
"git": false,
|
||||||
"install": true,
|
"install": true,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
|
# ISO 4217 currency code this Kitchen charges and displays prices in, and
|
||||||
|
# the BCP 47 locale used to format it (decimal separator, digit grouping).
|
||||||
|
# Default to EUR / de-DE (this deployment's location) when unset.
|
||||||
|
VITE_CURRENCY=EUR
|
||||||
|
VITE_LOCALE=de-DE
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# Used by `npm run db:seed-admin` to create/promote one admin account
|
||||||
|
# (can view every order at /admin/orders). Not read by the app itself.
|
||||||
|
ADMIN_EMAIL=
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
ADMIN_NAME=
|
||||||
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
|
||||||
|
</project>
|
||||||
@@ -12,17 +12,17 @@ Guidance for Claude Code when working in this repository.
|
|||||||
- Vite
|
- Vite
|
||||||
- SCSS Modules
|
- SCSS Modules
|
||||||
- Drizzle ORM + PostgreSQL
|
- Drizzle ORM + PostgreSQL
|
||||||
- [Auth: Better Auth / Clerk / custom / none yet]
|
- Better Auth
|
||||||
- [Package manager: pnpm / npm / bun]
|
- npm
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # start dev server (http://localhost:3000)
|
npm run dev # start dev server (http://localhost:3000)
|
||||||
pnpm build # production build
|
npm run build # production build
|
||||||
pnpm typecheck # tsc --noEmit
|
npm run typecheck # tsc --noEmit
|
||||||
pnpm lint # eslint
|
npm run lint # eslint
|
||||||
pnpm test # [test runner, e.g. vitest]
|
npm test # vitest run
|
||||||
```
|
```
|
||||||
|
|
||||||
Always run `typecheck` and `lint` before considering a task done. If `build`
|
Always run `typecheck` and `lint` before considering a task done. If `build`
|
||||||
@@ -38,18 +38,24 @@ src/
|
|||||||
├── routeTree.gen.ts # AUTO-GENERATED — never edit by hand
|
├── routeTree.gen.ts # AUTO-GENERATED — never edit by hand
|
||||||
├── router.tsx # router instance/config
|
├── router.tsx # router instance/config
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── ui/ # base primitives (buttons, inputs, etc.)
|
│ ├── ui/ # base primitives (buttons, inputs, etc.) — not
|
||||||
│ └── <domain>/ # feature-specific components, e.g. posts/, auth/
|
│ │ populated yet; add it when a shared primitive
|
||||||
|
│ │ is needed
|
||||||
|
│ └── <domain>/ # feature-specific components, e.g. ordering/, orders/
|
||||||
|
├── domain/ # framework-free business logic, e.g. ordering.ts
|
||||||
|
├── server/ # server functions (createServerFn) callable from routes
|
||||||
|
├── integrations/ # third-party integration glue, e.g. better-auth/
|
||||||
├── lib/ # shared utilities, API clients, helpers
|
├── lib/ # shared utilities, API clients, helpers
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── schema.ts # Drizzle schema definitions
|
│ ├── schema.ts # Drizzle schema definitions
|
||||||
│ ├── index.ts # Drizzle client instance
|
│ ├── index.ts # Drizzle client instance
|
||||||
│ └── migrations/ # generated SQL migrations — never hand-edit
|
│ └── seed.ts # sample data for local dev / tests
|
||||||
├── styles/
|
├── styles/
|
||||||
│ ├── globals.scss # global styles, resets
|
│ ├── globals.scss # global styles, resets
|
||||||
│ └── _variables.scss # shared SCSS variables/mixins (partial)
|
│ └── _variables.scss # shared SCSS variables/mixins (partial)
|
||||||
public/ # static assets
|
public/ # static assets
|
||||||
drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
|
drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
|
||||||
|
drizzle/ # generated SQL migrations — never hand-edit
|
||||||
```
|
```
|
||||||
|
|
||||||
### Routing conventions
|
### Routing conventions
|
||||||
@@ -72,7 +78,8 @@ drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
|
|||||||
- Class names in `.module.scss` files are camelCase (e.g. `.cardHeader`, not
|
- Class names in `.module.scss` files are camelCase (e.g. `.cardHeader`, not
|
||||||
`.card-header`) so they read naturally as `styles.cardHeader`.
|
`.card-header`) so they read naturally as `styles.cardHeader`.
|
||||||
- Shared tokens (colors, spacing, breakpoints) live in `src/styles/_variables.scss`
|
- Shared tokens (colors, spacing, breakpoints) live in `src/styles/_variables.scss`
|
||||||
and are pulled in with `@use '~/styles/variables' as *;` — don't hardcode
|
and are pulled in with a relative `@use '../styles/variables' as *;` (Sass
|
||||||
|
doesn't resolve the `#/`/`@/` TS path aliases) — don't hardcode
|
||||||
colors/spacing that already exist as a variable.
|
colors/spacing that already exist as a variable.
|
||||||
- `src/styles/globals.scss` is for resets and truly global rules only
|
- `src/styles/globals.scss` is for resets and truly global rules only
|
||||||
(imported once in `__root.tsx`); component-specific styles always go in a
|
(imported once in `__root.tsx`); component-specific styles always go in a
|
||||||
@@ -93,7 +100,10 @@ drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
|
|||||||
the `function` keyword — e.g. `const getUser = async (id: string) => {...}`
|
the `function` keyword — e.g. `const getUser = async (id: string) => {...}`
|
||||||
and `export const Button = (props: ButtonProps) => {...}`.
|
and `export const Button = (props: ButtonProps) => {...}`.
|
||||||
- [Naming conventions: PascalCase components, camelCase functions/vars, etc.]
|
- [Naming conventions: PascalCase components, camelCase functions/vars, etc.]
|
||||||
- [Import order / path aliases, e.g. `~/` or `@/` for `src/`]
|
- Path aliases `#/*` and `@/*` both resolve to `src/*` (see `tsconfig.json`
|
||||||
|
and `package.json`'s `imports` field) — use either for TS/TSX imports.
|
||||||
|
SCSS files can't use these aliases; use relative `@use`/`@import` paths
|
||||||
|
there instead.
|
||||||
- Co-locate a component's test file next to it unless it's a shared
|
- Co-locate a component's test file next to it unless it's a shared
|
||||||
primitive in `components/ui/` (see "Styling" for `.module.scss` placement).
|
primitive in `components/ui/` (see "Styling" for `.module.scss` placement).
|
||||||
- No comments unless they're unavoidable for understanding — e.g. explaining
|
- No comments unless they're unavoidable for understanding — e.g. explaining
|
||||||
@@ -116,14 +126,17 @@ drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
|
|||||||
- After changing `schema.ts`, generate a migration rather than editing the
|
- After changing `schema.ts`, generate a migration rather than editing the
|
||||||
database by hand:
|
database by hand:
|
||||||
```bash
|
```bash
|
||||||
pnpm drizzle-kit generate # create a migration from schema changes
|
npm run db:generate # create a migration from schema changes
|
||||||
pnpm drizzle-kit migrate # apply pending migrations
|
npm run db:migrate # apply pending migrations
|
||||||
```
|
```
|
||||||
- Never hand-edit files under `db/migrations/` — they're generated and
|
- Never hand-edit files under `drizzle/` (the migrations output directory,
|
||||||
should be treated as a history, not touched retroactively.
|
configured in `drizzle.config.ts`) — they're generated and should be
|
||||||
- [Connection details: env var name for `DATABASE_URL`, whether using a
|
treated as a history, not touched retroactively.
|
||||||
connection pool like `postgres.js` or `node-postgres`, local dev DB setup
|
- Connection string comes from `DATABASE_URL` (see `.env.example`), read via
|
||||||
e.g. Docker Compose]
|
`drizzle-orm/node-postgres`. Local dev DB is a `docker-compose.yml`
|
||||||
|
Postgres container — see the README's "Local development database"
|
||||||
|
section. `.env.local` is picked up automatically by both the dev server
|
||||||
|
and `drizzle-kit`; no manual sourcing needed.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -134,7 +147,7 @@ drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
|
|||||||
|
|
||||||
## Things to avoid
|
## Things to avoid
|
||||||
|
|
||||||
- Don't hand-edit `routeTree.gen.ts` or files under `db/migrations/`.
|
- Don't hand-edit `routeTree.gen.ts` or files under `drizzle/`.
|
||||||
- Don't run destructive Drizzle commands (`drizzle-kit push` against a
|
- Don't run destructive Drizzle commands (`drizzle-kit push` against a
|
||||||
populated DB, or anything that drops columns/tables) without confirming
|
populated DB, or anything that drops columns/tables) without confirming
|
||||||
first — prefer `generate` + `migrate` so changes are reviewable.
|
first — prefer `generate` + `migrate` so changes are reviewable.
|
||||||
|
|||||||
+20
-2
@@ -16,16 +16,30 @@ _Avoid_: Store, location (as a separate multi-tenant concept — this project ha
|
|||||||
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
|
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
|
||||||
|
|
||||||
**Order**:
|
**Order**:
|
||||||
A customer's placed, paid request, containing one or more Order Items. An Order is complete only once every Order Item within it has been picked up.
|
A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up, cancelled, or discarded. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.)
|
||||||
|
|
||||||
|
**Charge**:
|
||||||
|
The single payment transaction covering an Order's full total, created once at placement time. Can be partially refunded multiple times over the Order's life — once per Order Item that gets cancelled — rather than each Order Item carrying its own separate payment.
|
||||||
|
_Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments)
|
||||||
|
|
||||||
**Order Item**:
|
**Order Item**:
|
||||||
A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time before its first Preparation Step begins; locked once that step starts.
|
A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress). Separately, the Kitchen itself can cancel an Order Item at any point before it's ready (e.g. a Machine fault), even mid-Step, also with a refund.
|
||||||
_Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state)
|
_Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state)
|
||||||
|
|
||||||
|
**Guest Access Token**:
|
||||||
|
A bearer credential issued to a guest customer (one without an account) at Order placement time. Since a guest has no session, this token is the only proof of ownership accepted on later calls against that Order (status lookup, cancellation, pickup).
|
||||||
|
_Avoid_: Session (guests never get a session; this is a different, bearer-credential mechanism)
|
||||||
|
|
||||||
|
**Recipe**:
|
||||||
|
The ordered list of Preparation Steps required to prepare a Menu Item. A Menu Item's Recipe can be edited later by an operator; an Order Item freezes a copy of the Recipe at the moment it's ordered, so a later Recipe edit never reshapes a Step list a customer already paid for.
|
||||||
|
|
||||||
**Preparation Step**:
|
**Preparation Step**:
|
||||||
One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion.
|
One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion.
|
||||||
_Avoid_: Stage (use Step consistently)
|
_Avoid_: Stage (use Step consistently)
|
||||||
|
|
||||||
|
**Delay**:
|
||||||
|
Extra time a Machine reports on top of a claimed Preparation Step's estimated duration. Delays add to the Order Item's time to completion; the customer only sees the updated time, never a delay notice.
|
||||||
|
|
||||||
**Machine**:
|
**Machine**:
|
||||||
A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel.
|
A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel.
|
||||||
|
|
||||||
@@ -35,5 +49,9 @@ A capability label on a Machine (e.g. "oven", "grill", "assembly") describing wh
|
|||||||
**Queue**:
|
**Queue**:
|
||||||
The set of Preparation Steps awaiting a Machine carrying a given Machine Tag, ordered by turn. When such a Machine becomes idle, it takes the next eligible Step from that Tag's Queue.
|
The set of Preparation Steps awaiting a Machine carrying a given Machine Tag, ordered by turn. When such a Machine becomes idle, it takes the next eligible Step from that Tag's Queue.
|
||||||
|
|
||||||
|
**Pickup Box**:
|
||||||
|
A numbered locker at a Kitchen that a ready Order Item is placed in for the customer to collect. Each ready Order Item gets the lowest-numbered free box; if none is free, it waits for the next one to free up. The customer opens the box from their Order and closes it again after taking the item out — closing the box is what marks the Order Item picked up and frees the box. The customer has 15 minutes from when the item goes into the box (the pickup window); after that the item is discarded and the box freed, with no refund. A box that is open when the window runs out isn't emptied, since its customer is taking the item out.
|
||||||
|
_Avoid_: Counter, shelf (the box is a lockable compartment the customer operates themselves)
|
||||||
|
|
||||||
**Operator**:
|
**Operator**:
|
||||||
The human role attached to a Kitchen, responsible only for physically restocking ingredients and repairing machines. Never involved in cooking or fulfilling orders.
|
The human role attached to a Kitchen, responsible only for physically restocking ingredients and repairing machines. Never involved in cooking or fulfilling orders.
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This requires a running Postgres database — see
|
||||||
|
[Local development database](#local-development-database) below before your
|
||||||
|
first run.
|
||||||
|
|
||||||
# Building For Production
|
# Building For Production
|
||||||
|
|
||||||
To build this application for production:
|
To build this application for production:
|
||||||
@@ -19,16 +23,10 @@ npm run build
|
|||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
This project uses SCSS Modules. Every component that needs styles gets a
|
||||||
|
co-located `Component.module.scss` next to `Component.tsx`; shared tokens
|
||||||
### Removing Tailwind CSS
|
(colors, spacing, breakpoints) live in `src/styles/_variables.scss`. See
|
||||||
|
`CLAUDE.md` for the full styling conventions.
|
||||||
If you prefer not to use Tailwind CSS:
|
|
||||||
|
|
||||||
1. Remove the demo pages in `src/routes/demo/`
|
|
||||||
2. Replace the Tailwind import in `src/styles.css` with your own styles
|
|
||||||
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
|
|
||||||
4. Remove `@tailwindcss/vite` and `tailwindcss` from `package.json`
|
|
||||||
|
|
||||||
## Linting & Formatting
|
## Linting & Formatting
|
||||||
|
|
||||||
@@ -42,39 +40,77 @@ npm run check
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Setting up Better Auth
|
## Local development database
|
||||||
|
|
||||||
1. Generate and set the `BETTER_AUTH_SECRET` environment variable in your `.env.local`:
|
This project requires a Postgres database — both for its own data (Drizzle,
|
||||||
|
see `src/db/schema.ts`) and for Better Auth's own tables (users, sessions,
|
||||||
```bash
|
etc). A `docker-compose.yml` at the repo root starts one:
|
||||||
npx -y @better-auth/cli secret
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Visit the [Better Auth documentation](https://www.better-auth.com) to unlock the full potential of authentication in your app.
|
|
||||||
|
|
||||||
### Adding a Database (Optional)
|
|
||||||
|
|
||||||
Better Auth can work in stateless mode, but to persist user data, add a database:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/auth.ts
|
|
||||||
import { betterAuth } from "better-auth";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
export const auth = betterAuth({
|
|
||||||
database: new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
}),
|
|
||||||
// ... rest of config
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run migrations:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y @better-auth/cli migrate
|
docker compose up -d # starts Postgres on localhost:5433
|
||||||
|
cp .env.example .env.local # sets DATABASE_URL to match
|
||||||
|
npm run db:migrate # applies this project's Drizzle migrations
|
||||||
|
npm run auth:migrate # applies Better Auth's own tables
|
||||||
|
npm run db:seed # seeds a sample Brand, menu, and Machines
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`DATABASE_URL` in `.env.local` is picked up automatically by both the dev
|
||||||
|
server and the `drizzle-kit` commands above — no other wiring needed.
|
||||||
|
|
||||||
|
After changing `src/db/schema.ts`, generate and apply a new migration rather
|
||||||
|
than editing the database by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setting up Better Auth
|
||||||
|
|
||||||
|
Generate and set the `BETTER_AUTH_SECRET` environment variable in your
|
||||||
|
`.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
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: ghost_kitchen
|
||||||
|
ports:
|
||||||
|
- '5433:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Order Items are independent, not the Order
|
# Order Items are independent, not the Order
|
||||||
|
|
||||||
Preparation, queuing, pickup, and cancellation are all tracked per Order Item, not per Order. An Order is just a bundle that is "complete" once every Order Item in it has been picked up — but each Order Item cooks on its own Machine, becomes ready on its own, gets notified and picked up on its own, and can be cancelled on its own (locked only once its own first Preparation Step starts). We chose this over treating the Order as the atomic unit because items in the same order can have very different cook times (a pizza vs. a burger); forcing them to stay in lockstep would mean a finished item goes cold waiting on a slower one, or a customer loses the ability to cancel a not-yet-started item just because a sibling item already started cooking.
|
Preparation, queuing, pickup, and cancellation are all tracked per Order Item, not per Order. An Order is just a bundle that is "complete" once every Order Item in it has reached a terminal state — but each Order Item cooks on its own Machine, becomes ready on its own, gets notified and picked up on its own, and can be cancelled on its own (locked only once its own current Preparation Step is claimed by a Machine). We chose this over treating the Order as the atomic unit because items in the same order can have very different cook times (a pizza vs. a burger); forcing them to stay in lockstep would mean a finished item goes cold waiting on a slower one, or a customer loses the ability to cancel a not-yet-started item just because a sibling item already started cooking.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Single Charge per Order, refunded incrementally per cancelled Item
|
||||||
|
|
||||||
|
An Order is charged once, in full, at placement time — before any Order Item exists — rather than opening a separate charge per Order Item. When an Order Item is later cancelled, its share of the total is refunded against that same Charge, so one Charge can be partially refunded multiple times over an Order's life, once per cancelled Item. We chose one Charge per Order because it means a single payment authorization per customer checkout, keeping the payment-provider integration and the customer-facing checkout flow simple; the alternative — a Charge per Order Item — would let items be refunded fully independently of one another, but would mean authorizing the customer's card multiple times per Order and tracking a payment-provider transaction per Item instead of per Order.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Recipe snapshot per Order Item
|
||||||
|
|
||||||
|
An Order Item's full Recipe (every Preparation Step, in order) is copied from the Menu Item's live Recipe into a per-Order-Item `orderItemSteps` table at order-placement time, rather than each Step being looked up from the live Recipe as the Order Item progresses. We chose this because price is already snapshotted per Order Item for the same reason ("later menu changes don't rewrite history") — an operator editing a Menu Item's Recipe (reordering Steps, changing a Step's Machine Tag or duration) should never reshape an Order Item a customer already paid for and is partway through cooking. The live `menuItemSteps` table remains the source of truth for menu browsing and for new Orders; only `orderItemSteps` is read once an Order Item exists.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE "order_item_steps" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order_item_id" integer NOT NULL,
|
||||||
|
"sequence" integer NOT NULL,
|
||||||
|
"machine_tag_id" integer NOT NULL,
|
||||||
|
"estimated_duration_seconds" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_item_steps" ADD CONSTRAINT "order_item_steps_order_item_id_order_items_id_fk" FOREIGN KEY ("order_item_id") REFERENCES "public"."order_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_item_steps" ADD CONSTRAINT "order_item_steps_machine_tag_id_machine_tags_id_fk" FOREIGN KEY ("machine_tag_id") REFERENCES "public"."machine_tags"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "menu_item_steps" ADD COLUMN "name" text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_item_steps" ADD COLUMN "name" text NOT NULL;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "menu_item_options" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"menu_item_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"price_delta_cents" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "order_item_options" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order_item_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"price_delta_cents" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "menu_item_options" ADD CONSTRAINT "menu_item_options_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_item_options" ADD CONSTRAINT "order_item_options_order_item_id_order_items_id_fk" FOREIGN KEY ("order_item_id") REFERENCES "public"."order_items"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE "contact_messages" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "allergens" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "menu_item_allergens" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"menu_item_id" integer NOT NULL,
|
||||||
|
"allergen_id" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_allergen_id_allergens_id_fk" FOREIGN KEY ("allergen_id") REFERENCES "public"."allergens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "allergens_name_idx" ON "allergens" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "menu_item_allergens_item_allergen_idx" ON "menu_item_allergens" USING btree ("menu_item_id","allergen_id");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "order_items" ADD COLUMN "cancelled_by" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "preparation_step_instances" ADD COLUMN "delay_seconds" integer DEFAULT 0 NOT NULL;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "pickup_boxes" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"number" integer NOT NULL,
|
||||||
|
"is_open" boolean DEFAULT false NOT NULL,
|
||||||
|
CONSTRAINT "pickup_boxes_number_unique" UNIQUE("number")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_items" ADD COLUMN "pickup_box_id" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_pickup_box_id_pickup_boxes_id_fk" FOREIGN KEY ("pickup_box_id") REFERENCES "public"."pickup_boxes"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "order_items" ADD COLUMN "picked_up_at" timestamp;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "order_items" ADD COLUMN "placed_in_box_at" timestamp;
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
{
|
||||||
|
"id": "aecfb37f-fb88-44ba-8e02-f290a3929293",
|
||||||
|
"prevId": "816e0a1c-3005-433c-911b-2e006cc9e91b",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
{
|
||||||
|
"id": "cd474707-7446-42df-afd9-44d66a9a03a2",
|
||||||
|
"prevId": "3761e215-1f2d-4374-83af-942ef92e41df",
|
||||||
|
"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_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_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_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
{
|
||||||
|
"id": "83dc14cf-82fb-43fc-8b7d-fd377bf48689",
|
||||||
|
"prevId": "cd474707-7446-42df-afd9-44d66a9a03a2",
|
||||||
|
"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.contact_messages": {
|
||||||
|
"name": "contact_messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"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_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_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_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
{
|
||||||
|
"id": "4086d6cd-5c93-4617-a15b-a90331b459a1",
|
||||||
|
"prevId": "83dc14cf-82fb-43fc-8b7d-fd377bf48689",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.allergens": {
|
||||||
|
"name": "allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"allergens_name_idx": {
|
||||||
|
"name": "allergens_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.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.contact_messages": {
|
||||||
|
"name": "contact_messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"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_allergens": {
|
||||||
|
"name": "menu_item_allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"allergen_id": {
|
||||||
|
"name": "allergen_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"menu_item_allergens_item_allergen_idx": {
|
||||||
|
"name": "menu_item_allergens_item_allergen_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "menu_item_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "allergen_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_allergens_allergen_id_allergens_id_fk": {
|
||||||
|
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "allergens",
|
||||||
|
"columnsFrom": [
|
||||||
|
"allergen_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_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_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
{
|
||||||
|
"id": "1cec3157-9481-4f6a-83b0-a79064d2bed5",
|
||||||
|
"prevId": "4086d6cd-5c93-4617-a15b-a90331b459a1",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.allergens": {
|
||||||
|
"name": "allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"allergens_name_idx": {
|
||||||
|
"name": "allergens_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.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.contact_messages": {
|
||||||
|
"name": "contact_messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"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_allergens": {
|
||||||
|
"name": "menu_item_allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"allergen_id": {
|
||||||
|
"name": "allergen_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"menu_item_allergens_item_allergen_idx": {
|
||||||
|
"name": "menu_item_allergens_item_allergen_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "menu_item_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "allergen_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_allergens_allergen_id_allergens_id_fk": {
|
||||||
|
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "allergens",
|
||||||
|
"columnsFrom": [
|
||||||
|
"allergen_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_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_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_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'"
|
||||||
|
},
|
||||||
|
"cancelled_by": {
|
||||||
|
"name": "cancelled_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"delay_seconds": {
|
||||||
|
"name": "delay_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
{
|
||||||
|
"id": "42d1f3c1-18f8-42a3-ac41-9e29a4b14add",
|
||||||
|
"prevId": "1cec3157-9481-4f6a-83b0-a79064d2bed5",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.allergens": {
|
||||||
|
"name": "allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"allergens_name_idx": {
|
||||||
|
"name": "allergens_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.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.contact_messages": {
|
||||||
|
"name": "contact_messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"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_allergens": {
|
||||||
|
"name": "menu_item_allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"allergen_id": {
|
||||||
|
"name": "allergen_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"menu_item_allergens_item_allergen_idx": {
|
||||||
|
"name": "menu_item_allergens_item_allergen_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "menu_item_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "allergen_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_allergens_allergen_id_allergens_id_fk": {
|
||||||
|
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "allergens",
|
||||||
|
"columnsFrom": [
|
||||||
|
"allergen_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_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_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_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'"
|
||||||
|
},
|
||||||
|
"cancelled_by": {
|
||||||
|
"name": "cancelled_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"pickup_box_id": {
|
||||||
|
"name": "pickup_box_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"order_items_pickup_box_id_pickup_boxes_id_fk": {
|
||||||
|
"name": "order_items_pickup_box_id_pickup_boxes_id_fk",
|
||||||
|
"tableFrom": "order_items",
|
||||||
|
"tableTo": "pickup_boxes",
|
||||||
|
"columnsFrom": [
|
||||||
|
"pickup_box_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.pickup_boxes": {
|
||||||
|
"name": "pickup_boxes",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"name": "number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"is_open": {
|
||||||
|
"name": "is_open",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"pickup_boxes_number_unique": {
|
||||||
|
"name": "pickup_boxes_number_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"number"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"delay_seconds": {
|
||||||
|
"name": "delay_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,948 @@
|
|||||||
|
{
|
||||||
|
"id": "4826f90c-c779-4b2d-ab6e-491e72dbaee8",
|
||||||
|
"prevId": "42d1f3c1-18f8-42a3-ac41-9e29a4b14add",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.allergens": {
|
||||||
|
"name": "allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"allergens_name_idx": {
|
||||||
|
"name": "allergens_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.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.contact_messages": {
|
||||||
|
"name": "contact_messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"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_allergens": {
|
||||||
|
"name": "menu_item_allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"allergen_id": {
|
||||||
|
"name": "allergen_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"menu_item_allergens_item_allergen_idx": {
|
||||||
|
"name": "menu_item_allergens_item_allergen_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "menu_item_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "allergen_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_allergens_allergen_id_allergens_id_fk": {
|
||||||
|
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "allergens",
|
||||||
|
"columnsFrom": [
|
||||||
|
"allergen_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_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_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_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'"
|
||||||
|
},
|
||||||
|
"cancelled_by": {
|
||||||
|
"name": "cancelled_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"pickup_box_id": {
|
||||||
|
"name": "pickup_box_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"picked_up_at": {
|
||||||
|
"name": "picked_up_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"order_items_pickup_box_id_pickup_boxes_id_fk": {
|
||||||
|
"name": "order_items_pickup_box_id_pickup_boxes_id_fk",
|
||||||
|
"tableFrom": "order_items",
|
||||||
|
"tableTo": "pickup_boxes",
|
||||||
|
"columnsFrom": [
|
||||||
|
"pickup_box_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.pickup_boxes": {
|
||||||
|
"name": "pickup_boxes",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"name": "number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"is_open": {
|
||||||
|
"name": "is_open",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"pickup_boxes_number_unique": {
|
||||||
|
"name": "pickup_boxes_number_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"number"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"delay_seconds": {
|
||||||
|
"name": "delay_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,954 @@
|
|||||||
|
{
|
||||||
|
"id": "79d9d9d2-6e88-41ed-8d15-160c3549c881",
|
||||||
|
"prevId": "4826f90c-c779-4b2d-ab6e-491e72dbaee8",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.allergens": {
|
||||||
|
"name": "allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"allergens_name_idx": {
|
||||||
|
"name": "allergens_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.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.contact_messages": {
|
||||||
|
"name": "contact_messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"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_allergens": {
|
||||||
|
"name": "menu_item_allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"allergen_id": {
|
||||||
|
"name": "allergen_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"menu_item_allergens_item_allergen_idx": {
|
||||||
|
"name": "menu_item_allergens_item_allergen_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "menu_item_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "allergen_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_allergens_allergen_id_allergens_id_fk": {
|
||||||
|
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "allergens",
|
||||||
|
"columnsFrom": [
|
||||||
|
"allergen_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_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_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_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'"
|
||||||
|
},
|
||||||
|
"cancelled_by": {
|
||||||
|
"name": "cancelled_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"pickup_box_id": {
|
||||||
|
"name": "pickup_box_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"placed_in_box_at": {
|
||||||
|
"name": "placed_in_box_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"picked_up_at": {
|
||||||
|
"name": "picked_up_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"order_items_pickup_box_id_pickup_boxes_id_fk": {
|
||||||
|
"name": "order_items_pickup_box_id_pickup_boxes_id_fk",
|
||||||
|
"tableFrom": "order_items",
|
||||||
|
"tableTo": "pickup_boxes",
|
||||||
|
"columnsFrom": [
|
||||||
|
"pickup_box_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.pickup_boxes": {
|
||||||
|
"name": "pickup_boxes",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"name": "number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"is_open": {
|
||||||
|
"name": "is_open",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"pickup_boxes_number_unique": {
|
||||||
|
"name": "pickup_boxes_number_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"number"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"delay_seconds": {
|
||||||
|
"name": "delay_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,69 @@
|
|||||||
"when": 1788972318759,
|
"when": 1788972318759,
|
||||||
"tag": "0001_graceful_frank_castle",
|
"tag": "0001_graceful_frank_castle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788976393301,
|
||||||
|
"tag": "0002_large_lake",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789028563859,
|
||||||
|
"tag": "0003_soft_psynapse",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789036823859,
|
||||||
|
"tag": "0004_misty_hedge_knight",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789636006749,
|
||||||
|
"tag": "0005_tan_power_man",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789637974330,
|
||||||
|
"tag": "0006_worried_shooting_star",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790804884144,
|
||||||
|
"tag": "0007_dazzling_norman_osborn",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790845995013,
|
||||||
|
"tag": "0008_absurd_the_anarchist",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790888552762,
|
||||||
|
"tag": "0009_damp_ender_wiggin",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790889640651,
|
||||||
|
"tag": "0010_lush_black_bolt",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+1815
-302
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -6,7 +6,7 @@
|
|||||||
"#/*": "./src/*"
|
"#/*": "./src/*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000 --host",
|
||||||
"generate-routes": "tsr generate",
|
"generate-routes": "tsr generate",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:pull": "drizzle-kit pull",
|
"db:pull": "drizzle-kit pull",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
"db:seed-admin": "tsx src/db/seed-admin.ts",
|
||||||
|
"auth:migrate": "better-auth migrate",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"format": "prettier --write . && eslint --fix",
|
"format": "prettier --write . && eslint --fix",
|
||||||
"check": "prettier --check .",
|
"check": "prettier --check .",
|
||||||
@@ -23,7 +26,6 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
|
||||||
"@tanstack/react-devtools": "latest",
|
"@tanstack/react-devtools": "latest",
|
||||||
"@tanstack/react-router": "latest",
|
"@tanstack/react-router": "latest",
|
||||||
"@tanstack/react-router-devtools": "latest",
|
"@tanstack/react-router-devtools": "latest",
|
||||||
@@ -31,14 +33,15 @@
|
|||||||
"better-auth": "^1.5.3",
|
"better-auth": "^1.5.3",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"framer-motion": "^13.2.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"stripe": "^22.6.1",
|
"stripe": "^22.6.1",
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"zod": "^4.5.4"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@better-auth/cli": "^1.4.21",
|
||||||
"@tanstack/devtools-vite": "latest",
|
"@tanstack/devtools-vite": "latest",
|
||||||
"@tanstack/eslint-config": "latest",
|
"@tanstack/eslint-config": "latest",
|
||||||
"@tanstack/router-cli": "^1.132.0",
|
"@tanstack/router-cli": "^1.132.0",
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"eslint": "^9.20.0",
|
"eslint": "^9.20.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"sass": "^1.104.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Executable
+227
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# A wizard walks a human through a manual procedure, step by step.
|
||||||
|
# Generated by the /wizard skill.
|
||||||
|
#
|
||||||
|
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
|
||||||
|
# it. Author the per-step stages below the marker.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Wizard library: delightful, consistent UX, identical across every wizard.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
|
||||||
|
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
|
||||||
|
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
|
||||||
|
else
|
||||||
|
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Author sets this at the top of the stages section.
|
||||||
|
TOTAL_STAGES=0
|
||||||
|
|
||||||
|
_STAGE_INDEX=0
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
|
||||||
|
WRITTEN_SECRET=() # secret NAMEs set this run
|
||||||
|
SKIPPED=() # things we couldn't do (e.g. gh missing)
|
||||||
|
|
||||||
|
# _clear wipes the terminal so only the current step is on screen. No-op when
|
||||||
|
# output isn't a terminal, so piped logs stay readable.
|
||||||
|
_clear() {
|
||||||
|
[[ -t 1 ]] || return 0
|
||||||
|
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# banner "Title" shows the opening frame: what this wizard does.
|
||||||
|
banner() {
|
||||||
|
_clear
|
||||||
|
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
|
||||||
|
printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET"
|
||||||
|
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
|
||||||
|
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
|
||||||
|
printf ' later, since it remembers values already saved.%s\n' "$RESET"
|
||||||
|
pause "Ready to start?"
|
||||||
|
}
|
||||||
|
|
||||||
|
# stage "Name" clears the screen, then announces a stage and shows progress.
|
||||||
|
# Clearing keeps only the current step on screen.
|
||||||
|
stage() {
|
||||||
|
_clear
|
||||||
|
_STAGE_INDEX=$((_STAGE_INDEX + 1))
|
||||||
|
printf '\n%s%s▸ Stage %s/%s · %s%s\n' \
|
||||||
|
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET"
|
||||||
|
}
|
||||||
|
|
||||||
|
# say "..." prints a plain instruction line.
|
||||||
|
say() { printf ' %s\n' "$1"; }
|
||||||
|
# step "..." is a numbered-feeling action the human takes in the browser.
|
||||||
|
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
|
||||||
|
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
|
||||||
|
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
|
||||||
|
|
||||||
|
# open_url URL opens it in the human's browser, cross-platform incl. WSL.
|
||||||
|
open_url() {
|
||||||
|
local url="$1"
|
||||||
|
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
|
||||||
|
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
|
||||||
|
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
|
||||||
|
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
|
||||||
|
elif command -v open >/dev/null 2>&1; then open "$url"
|
||||||
|
else warn "couldn't open a browser; visit it manually: $url"; fi
|
||||||
|
} >/dev/null 2>&1 || warn "couldn't open a browser, so visit it manually: $url"
|
||||||
|
}
|
||||||
|
|
||||||
|
# pause "msg" waits for the human to confirm they've done the manual part.
|
||||||
|
pause() {
|
||||||
|
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
|
||||||
|
read -r _ || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# confirm "question" is a y/N gate; returns success on yes.
|
||||||
|
confirm() {
|
||||||
|
local reply=""
|
||||||
|
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
|
||||||
|
read -r reply || true
|
||||||
|
[[ "$reply" =~ ^[Yy] ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# _existing KEY: current value of KEY in ENV_FILE, if any.
|
||||||
|
_existing() {
|
||||||
|
[[ -f "$ENV_FILE" ]] || return 1
|
||||||
|
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
|
||||||
|
printf '%s' "${line#*=}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ask KEY "Prompt" reads a value into $KEY. Offers the existing .env value as
|
||||||
|
# a default on re-runs (Enter keeps it). Visible input (non-secret).
|
||||||
|
ask() {
|
||||||
|
local key="$1" prompt="$2" current input
|
||||||
|
current=$(_existing "$key" || true)
|
||||||
|
if [[ -n "$current" ]]; then
|
||||||
|
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
|
||||||
|
else
|
||||||
|
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
|
||||||
|
fi
|
||||||
|
read -r input || true
|
||||||
|
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||||
|
printf -v "$key" '%s' "$input"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ask_secret KEY "Prompt" is like ask, but input is hidden.
|
||||||
|
ask_secret() {
|
||||||
|
local key="$1" prompt="$2" current input
|
||||||
|
current=$(_existing "$key" || true)
|
||||||
|
if [[ -n "$current" ]]; then
|
||||||
|
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
|
||||||
|
else
|
||||||
|
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
|
||||||
|
fi
|
||||||
|
read -rs input || true
|
||||||
|
printf '\n'
|
||||||
|
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||||
|
printf -v "$key" '%s' "$input"
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_env KEY VALUE upserts KEY=VALUE into ENV_FILE (creates it; replaces
|
||||||
|
# any existing line). Idempotent.
|
||||||
|
write_env() {
|
||||||
|
local key="$1" value="$2" tmp
|
||||||
|
touch "$ENV_FILE"
|
||||||
|
tmp=$(mktemp)
|
||||||
|
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||||
|
mv "$tmp" "$ENV_FILE"
|
||||||
|
WRITTEN_ENV+=("$key")
|
||||||
|
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# set_secret NAME VALUE sets a GitHub Actions repo secret via gh. Falls back
|
||||||
|
# to a warning (and records it) if gh is unavailable or unauthenticated.
|
||||||
|
set_secret() {
|
||||||
|
local name="$1" value="$2"
|
||||||
|
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||||
|
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
|
||||||
|
WRITTEN_SECRET+=("$name")
|
||||||
|
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
|
||||||
|
warn "skipped GitHub secret $name: gh not ready; set it later"
|
||||||
|
}
|
||||||
|
|
||||||
|
# set_var NAME VALUE sets a GitHub Actions repo variable (non-secret).
|
||||||
|
set_var() {
|
||||||
|
local name="$1" value="$2"
|
||||||
|
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||||
|
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
|
||||||
|
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
SKIPPED+=("GitHub variable $name")
|
||||||
|
warn "skipped GitHub variable $name, gh not ready; set it later"
|
||||||
|
}
|
||||||
|
|
||||||
|
# finish clears, then shows a closing summary of everything configured.
|
||||||
|
finish() {
|
||||||
|
_clear
|
||||||
|
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
|
||||||
|
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
|
||||||
|
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
|
||||||
|
if (( ${#SKIPPED[@]} )); then
|
||||||
|
printf '\n'; warn "still to do by hand:"
|
||||||
|
for s in "${SKIPPED[@]}"; do note " - $s"; done
|
||||||
|
fi
|
||||||
|
printf '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# STAGES: author this section. One stage() per step the human takes.
|
||||||
|
# Replace the example below. Set TOTAL_STAGES to match the stages you write.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TOTAL_STAGES=2
|
||||||
|
|
||||||
|
banner "Google Sign-In setup"
|
||||||
|
|
||||||
|
BETTER_AUTH_URL_VALUE=$(_existing BETTER_AUTH_URL || true)
|
||||||
|
BETTER_AUTH_URL_VALUE="${BETTER_AUTH_URL_VALUE:-http://localhost:3000}"
|
||||||
|
REDIRECT_URI="${BETTER_AUTH_URL_VALUE}/api/auth/callback/google"
|
||||||
|
|
||||||
|
# ── Stage 1: OAuth consent screen ──────────────────────────────────────────
|
||||||
|
stage "Google Cloud: OAuth consent screen"
|
||||||
|
say "Google won't let you create an OAuth client until a consent screen exists"
|
||||||
|
say "for the project. Skip straight through if yours already has one."
|
||||||
|
open_url "https://console.cloud.google.com/apis/credentials/consent"
|
||||||
|
step "Select or create a Google Cloud project for this app."
|
||||||
|
step "User type: 'External' (or 'Internal' if you're on a Workspace org and"
|
||||||
|
step " only your org's users will ever sign in)."
|
||||||
|
step "Fill in App name, User support email, and Developer contact email,"
|
||||||
|
step " then click through Save on the remaining screens (scopes, test users)."
|
||||||
|
pause "Done configuring the consent screen?"
|
||||||
|
|
||||||
|
# ── Stage 2: OAuth client ID ────────────────────────────────────────────────
|
||||||
|
stage "Google Cloud: create OAuth client ID"
|
||||||
|
open_url "https://console.cloud.google.com/apis/credentials"
|
||||||
|
step "Click '+ Create Credentials' → 'OAuth client ID'."
|
||||||
|
step "Application type: 'Web application'."
|
||||||
|
say "Authorized redirect URI to add:"
|
||||||
|
note " $REDIRECT_URI"
|
||||||
|
warn "If BETTER_AUTH_URL changes later (e.g. you deploy this), add that"
|
||||||
|
warn "domain's /api/auth/callback/google URI here too."
|
||||||
|
step "Paste the redirect URI above into 'Authorized redirect URIs', then click 'Create'."
|
||||||
|
step "A dialog shows the Client ID and Client secret — copy both."
|
||||||
|
ask GOOGLE_CLIENT_ID "Paste the Client ID:"
|
||||||
|
ask_secret GOOGLE_CLIENT_SECRET "Paste the Client secret:"
|
||||||
|
write_env GOOGLE_CLIENT_ID "$GOOGLE_CLIENT_ID"
|
||||||
|
write_env GOOGLE_CLIENT_SECRET "$GOOGLE_CLIENT_SECRET"
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
finish
|
||||||
|
note "Restart 'npm run dev' so the server picks up the new env vars."
|
||||||
|
note "Apple SSO needs a real HTTPS domain (no localhost) — re-run /wizard for"
|
||||||
|
note "that once you have one to test against."
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $space-3;
|
||||||
|
margin-bottom: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addLink {
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemPrice {
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMeta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editLink {
|
||||||
|
padding: $space-1 $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
color: $color-text;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
padding: $space-1 $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-danger;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmButton {
|
||||||
|
padding: $space-1 $space-3;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-danger;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $color-danger;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: $space-2 0 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { AdminMenuItem } from '#/domain/menu.ts'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './AdminMenuListView.module.scss'
|
||||||
|
|
||||||
|
type AdminMenuListViewProps = {
|
||||||
|
items: Array<AdminMenuItem>
|
||||||
|
onDelete: (menuItemId: number) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminMenuListView = ({
|
||||||
|
items,
|
||||||
|
onDelete,
|
||||||
|
}: AdminMenuListViewProps) => {
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const confirmDelete = async (menuItemId: number) => {
|
||||||
|
setDeletingId(menuItemId)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await onDelete(menuItemId)
|
||||||
|
} catch (deleteError) {
|
||||||
|
setError(
|
||||||
|
deleteError instanceof Error
|
||||||
|
? deleteError.message
|
||||||
|
: 'Something went wrong.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
setConfirmingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.heading}>Menu</h1>
|
||||||
|
<Link
|
||||||
|
to="/admin/menu/new"
|
||||||
|
className={styles.addLink}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
Add item
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className={styles.empty}>No menu items yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.items}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className={styles.item}>
|
||||||
|
<div className={styles.itemInfo}>
|
||||||
|
<div className={styles.itemHeader}>
|
||||||
|
<span className={styles.itemName}>{item.name}</span>
|
||||||
|
<span className={styles.itemPrice}>
|
||||||
|
{formatPrice(item.priceCents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.itemMeta}>
|
||||||
|
{item.steps.length} step
|
||||||
|
{item.steps.length === 1 ? '' : 's'} ·{' '}
|
||||||
|
{item.options.length} option
|
||||||
|
{item.options.length === 1 ? '' : 's'}
|
||||||
|
{item.allergens.length > 0
|
||||||
|
? ` · Contains: ${item.allergens.map((allergen) => allergen.name).join(', ')}`
|
||||||
|
: ''}
|
||||||
|
{item.description ? ` · ${item.description}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.itemActions}>
|
||||||
|
<Link
|
||||||
|
to="/admin/menu/$menuItemId"
|
||||||
|
params={{ menuItemId: String(item.id) }}
|
||||||
|
className={styles.editLink}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
{confirmingId === item.id ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.confirmButton}
|
||||||
|
disabled={deletingId === item.id}
|
||||||
|
onClick={() => void confirmDelete(item.id)}
|
||||||
|
>
|
||||||
|
{deletingId === item.id ? 'Deleting…' : 'Confirm?'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onClick={() => setConfirmingId(item.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-4;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
padding: $space-2;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repeatingRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $space-2;
|
||||||
|
|
||||||
|
.field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $space-1;
|
||||||
|
padding-bottom: $space-2;
|
||||||
|
border-bottom: 1px solid $color-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepName {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrowField {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeButton {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-danger;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
margin-top: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addButton {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: $space-1 $space-3;
|
||||||
|
border: 1px dashed $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-primary;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergenList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergenOption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $color-text;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelLink {
|
||||||
|
color: $color-text-muted;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $color-danger;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminMenuItem,
|
||||||
|
AllergenOption,
|
||||||
|
MenuItemInput,
|
||||||
|
} from '#/domain/menu.ts'
|
||||||
|
import type { MachineTagOption } from '#/domain/kitchen.ts'
|
||||||
|
|
||||||
|
import styles from './MenuItemForm.module.scss'
|
||||||
|
|
||||||
|
type OptionRow = { name: string; priceDelta: string }
|
||||||
|
type StepRow = { name: string; machineTagId: string; durationSeconds: string }
|
||||||
|
|
||||||
|
const toOptionRows = (item?: AdminMenuItem): Array<OptionRow> =>
|
||||||
|
item?.options.map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
priceDelta: (option.priceDeltaCents / 100).toString(),
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
const toStepRows = (item?: AdminMenuItem): Array<StepRow> =>
|
||||||
|
item?.steps.map((step) => ({
|
||||||
|
name: step.name,
|
||||||
|
machineTagId: String(step.machineTagId),
|
||||||
|
durationSeconds: String(step.estimatedDurationSeconds),
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
type MenuItemFormProps = {
|
||||||
|
machineTags: Array<MachineTagOption>
|
||||||
|
allergenOptions: Array<AllergenOption>
|
||||||
|
initialItem?: AdminMenuItem
|
||||||
|
submitLabel: string
|
||||||
|
onSubmit: (input: MenuItemInput) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItemForm = ({
|
||||||
|
machineTags,
|
||||||
|
allergenOptions,
|
||||||
|
initialItem,
|
||||||
|
submitLabel,
|
||||||
|
onSubmit,
|
||||||
|
}: MenuItemFormProps) => {
|
||||||
|
const [name, setName] = useState(initialItem?.name ?? '')
|
||||||
|
const [description, setDescription] = useState(
|
||||||
|
initialItem?.description ?? '',
|
||||||
|
)
|
||||||
|
const [price, setPrice] = useState(
|
||||||
|
initialItem ? (initialItem.priceCents / 100).toString() : '',
|
||||||
|
)
|
||||||
|
const [options, setOptions] = useState<Array<OptionRow>>(
|
||||||
|
toOptionRows(initialItem),
|
||||||
|
)
|
||||||
|
const [steps, setSteps] = useState<Array<StepRow>>(toStepRows(initialItem))
|
||||||
|
const [allergenIds, setAllergenIds] = useState<Array<number>>(
|
||||||
|
initialItem?.allergens.map((allergen) => allergen.id) ?? [],
|
||||||
|
)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const updateOption = (index: number, patch: Partial<OptionRow>) =>
|
||||||
|
setOptions((rows) =>
|
||||||
|
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||||
|
)
|
||||||
|
const updateStep = (index: number, patch: Partial<StepRow>) =>
|
||||||
|
setSteps((rows) =>
|
||||||
|
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||||
|
)
|
||||||
|
const toggleAllergen = (allergenId: number) =>
|
||||||
|
setAllergenIds((ids) =>
|
||||||
|
ids.includes(allergenId)
|
||||||
|
? ids.filter((id) => id !== allergenId)
|
||||||
|
: [...ids, allergenId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (steps.length === 0) {
|
||||||
|
setError('Add at least one Preparation Step — otherwise this item can never be cooked.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: MenuItemInput = {
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
priceCents: Math.round(Number(price) * 100),
|
||||||
|
options: options
|
||||||
|
.filter((option) => option.name.trim() !== '')
|
||||||
|
.map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: Math.round(Number(option.priceDelta || '0') * 100),
|
||||||
|
})),
|
||||||
|
steps: steps.map((step) => ({
|
||||||
|
name: step.name,
|
||||||
|
machineTagId: Number(step.machineTagId),
|
||||||
|
estimatedDurationSeconds: Math.round(Number(step.durationSeconds)),
|
||||||
|
})),
|
||||||
|
allergenIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await onSubmit(input)
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(
|
||||||
|
submitError instanceof Error
|
||||||
|
? submitError.message
|
||||||
|
: 'Something went wrong.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={styles.form} onSubmit={submit}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
Price
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={price}
|
||||||
|
onChange={(event) => setPrice(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<p className={styles.sectionTitle}>Options</p>
|
||||||
|
</div>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<div key={index} className={styles.repeatingRow}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={option.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateOption(index, { name: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`${styles.field} ${styles.narrowField}`}>
|
||||||
|
Price delta
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={option.priceDelta}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateOption(index, { priceDelta: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeButton}
|
||||||
|
aria-label={`Remove option ${index + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setOptions((rows) => rows.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addButton}
|
||||||
|
onClick={() =>
|
||||||
|
setOptions((rows) => [...rows, { name: '', priceDelta: '0' }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<p className={styles.sectionTitle}>Preparation steps</p>
|
||||||
|
</div>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={index} className={styles.repeatingRow}>
|
||||||
|
<label className={`${styles.field} ${styles.stepName}`}>
|
||||||
|
Step name
|
||||||
|
<input
|
||||||
|
value={step.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStep(index, { name: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`${styles.field} ${styles.narrowField}`}>
|
||||||
|
Machine
|
||||||
|
<select
|
||||||
|
value={step.machineTagId}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStep(index, { machineTagId: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select…
|
||||||
|
</option>
|
||||||
|
{machineTags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.id}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={`${styles.field} ${styles.narrowField}`}>
|
||||||
|
Duration (s)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={step.durationSeconds}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStep(index, { durationSeconds: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeButton}
|
||||||
|
aria-label={`Remove step ${index + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setSteps((rows) => rows.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addButton}
|
||||||
|
onClick={() =>
|
||||||
|
setSteps((rows) => [
|
||||||
|
...rows,
|
||||||
|
{ name: '', machineTagId: '', durationSeconds: '60' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<p className={styles.sectionTitle}>Allergens</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.allergenList}>
|
||||||
|
{allergenOptions.map((allergen) => (
|
||||||
|
<label key={allergen.id} className={styles.allergenOption}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allergenIds.includes(allergen.id)}
|
||||||
|
onChange={() => toggleAllergen(allergen.id)}
|
||||||
|
/>
|
||||||
|
{allergen.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" className={styles.submitButton} disabled={submitting}>
|
||||||
|
{submitting ? 'Saving…' : submitLabel}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/admin/menu"
|
||||||
|
className={styles.cancelLink}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-2 $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
padding: $space-4;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import styles from './AuthCard.module.scss'
|
||||||
|
|
||||||
|
type AuthCardProps = {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
footer: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthCard = ({ title, children, footer }: AuthCardProps) => (
|
||||||
|
<main className={styles.page}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h1 className={styles.heading}>{title}</h1>
|
||||||
|
{children}
|
||||||
|
<p className={styles.footer}>{footer}</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google,
|
||||||
|
.apple {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $space-2;
|
||||||
|
padding: $space-2 $space-3;
|
||||||
|
border-radius: $radius;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google {
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-text;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #000;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { authClient } from '#/lib/auth-client.ts'
|
||||||
|
|
||||||
|
import styles from './SsoButtons.module.scss'
|
||||||
|
|
||||||
|
type Provider = 'google' | 'apple'
|
||||||
|
|
||||||
|
type SsoButtonsProps = {
|
||||||
|
onError: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const continueWith = async (
|
||||||
|
provider: Provider,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
) => {
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
|
provider,
|
||||||
|
callbackURL: '/',
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
onError(error.message ?? `Could not continue with ${provider}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GoogleIcon = () => (
|
||||||
|
<svg className={styles.icon} viewBox="0 0 18 18" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M17.64 9.2045c0-.6381-.0573-1.2518-.1636-1.8409H9v3.4814h4.8436c-.2086 1.125-.8427 2.0782-1.7959 2.7164v2.2581h2.9087c1.7018-1.5668 2.6836-3.874 2.6836-6.615z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M9 18c2.43 0 4.4673-.806 5.9564-2.1805l-2.9087-2.2581c-.8059.5404-1.8368.8595-3.0477.8595-2.3441 0-4.3273-1.5831-5.0345-3.7104H.9573v2.3318C2.4382 15.9832 5.4818 18 9 18z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M3.9655 10.71c-.18-.5404-.2823-1.1177-.2823-1.71s.1023-1.1696.2823-1.71V4.9582H.9573A8.9965 8.9965 0 0 0 0 9c0 1.4523.3477 2.8268.9573 4.0418L3.9655 10.71z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M9 3.5795c1.3214 0 2.5077.4541 3.4405 1.346l2.5813-2.5814C13.4632.8918 11.426 0 9 0 5.4818 0 2.4382 2.0168.9573 4.9582L3.9655 7.29C4.6727 5.1627 6.6559 3.5795 9 3.5795z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const AppleIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.icon}
|
||||||
|
viewBox="0 0 384 512"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SsoButtons = ({ onError }: SsoButtonsProps) => (
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.google}
|
||||||
|
onClick={() => continueWith('google', onError)}
|
||||||
|
>
|
||||||
|
<GoogleIcon />
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.apple}
|
||||||
|
onClick={() => continueWith('apple', onError)}
|
||||||
|
>
|
||||||
|
<AppleIcon />
|
||||||
|
Continue with Apple
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 2.25rem;
|
||||||
|
width: 2.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text-muted;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||||
|
|
||||||
|
import { useBasket } from '#/lib/basket.tsx'
|
||||||
|
|
||||||
|
import styles from './BasketLink.module.scss'
|
||||||
|
|
||||||
|
const MotionLink = motion.create(Link)
|
||||||
|
|
||||||
|
export const BasketIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M3 6h2l2.4 12.2a2 2 0 0 0 2 1.8h8.4a2 2 0 0 0 2-1.6L22 9H6" />
|
||||||
|
<circle cx="9" cy="20" r="1.4" />
|
||||||
|
<circle cx="18" cy="20" r="1.4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enter bounces in with a spring; exit uses a quick fixed-duration tween
|
||||||
|
// instead — a spring's slow settling tail would otherwise leave an
|
||||||
|
// invisible-but-still-interactive link sitting in the DOM for ~2s after it's
|
||||||
|
// visually gone.
|
||||||
|
const enterTransition = { type: 'spring', stiffness: 500, damping: 22 } as const
|
||||||
|
const exitTransition = { duration: 0.18, ease: 'easeIn' } as const
|
||||||
|
|
||||||
|
export const BasketLink = () => {
|
||||||
|
const { itemCount } = useBasket()
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
})
|
||||||
|
const onBasketPage = pathname === '/basket'
|
||||||
|
|
||||||
|
const pop = reduceMotion
|
||||||
|
? {
|
||||||
|
initial: { opacity: 0 },
|
||||||
|
animate: { opacity: 1, transition: exitTransition },
|
||||||
|
exit: { opacity: 0, transition: exitTransition },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
initial: { opacity: 0, scale: 0.4 },
|
||||||
|
animate: { opacity: 1, scale: 1, transition: enterTransition },
|
||||||
|
exit: { opacity: 0, scale: 0.4, transition: exitTransition },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{itemCount > 0 && !onBasketPage && (
|
||||||
|
<MotionLink
|
||||||
|
key="basket-link"
|
||||||
|
to="/basket"
|
||||||
|
className={styles.trigger}
|
||||||
|
aria-label={`Basket, ${itemCount} item${itemCount === 1 ? '' : 's'}`}
|
||||||
|
{...pop}
|
||||||
|
>
|
||||||
|
<BasketIcon />
|
||||||
|
<span className={styles.badge}>{itemCount}</span>
|
||||||
|
</MotionLink>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
margin-top: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $color-danger;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
type Customer =
|
||||||
|
| { type: 'guest'; name: string; contact: string }
|
||||||
|
| { type: 'account' }
|
||||||
|
|
||||||
|
type PlacedOrder = { id: number; guestAccessToken: string | null }
|
||||||
|
|
||||||
|
type CheckoutSectionProps = {
|
||||||
|
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
|
||||||
|
disabled: boolean
|
||||||
|
onPlaced: (order: PlacedOrder) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckoutSection = ({
|
||||||
|
items,
|
||||||
|
disabled,
|
||||||
|
onPlaced,
|
||||||
|
}: CheckoutSectionProps) => {
|
||||||
|
const { data: session, isPending } = authClient.useSession()
|
||||||
|
const [guestName, setGuestName] = useState('')
|
||||||
|
const [guestContact, setGuestContact] = useState('')
|
||||||
|
const [rememberedGuest, setRememberedGuest] = useState<GuestIdentity | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Read after mount, not during initial render, so the server-rendered and
|
||||||
|
// first client-rendered HTML match (no localStorage on the server) and
|
||||||
|
// React doesn't flag a hydration mismatch — same reasoning as
|
||||||
|
// BasketProvider.
|
||||||
|
useEffect(() => {
|
||||||
|
setRememberedGuest(getGuestIdentity())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const submit = async (customer: Customer) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const order = await placeOrder({ data: { items, customer } })
|
||||||
|
if (order.guestAccessToken) {
|
||||||
|
addGuestOrderRef({
|
||||||
|
orderId: order.id,
|
||||||
|
guestAccessToken: order.guestAccessToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (customer.type === 'guest') {
|
||||||
|
const identity = { name: customer.name, contact: customer.contact }
|
||||||
|
saveGuestIdentity(identity)
|
||||||
|
setRememberedGuest(identity)
|
||||||
|
}
|
||||||
|
onPlaced(order)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Could not place the order. Please try again.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending) return null
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.submitButton}
|
||||||
|
disabled={disabled || submitting}
|
||||||
|
onClick={() => submit({ type: 'account' })}
|
||||||
|
>
|
||||||
|
{submitting ? 'Placing order…' : 'Place order'}
|
||||||
|
</button>
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rememberedGuest) {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.submitButton}
|
||||||
|
disabled={disabled || submitting}
|
||||||
|
onClick={() => submit({ type: 'guest', ...rememberedGuest })}
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? 'Placing order…'
|
||||||
|
: `Place order as ${rememberedGuest.name}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.forgetButton}
|
||||||
|
onClick={() => {
|
||||||
|
clearGuestIdentity()
|
||||||
|
setRememberedGuest(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Not you?
|
||||||
|
</button>
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={styles.section}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void submit({ type: 'guest', name: guestName, contact: guestContact })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={guestName}
|
||||||
|
onChange={(event) => setGuestName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Email or phone
|
||||||
|
<input
|
||||||
|
value={guestContact}
|
||||||
|
onChange={(event) => setGuestContact(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.submitButton}
|
||||||
|
disabled={disabled || submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Placing order…' : 'Place order as guest'}
|
||||||
|
</button>
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.customizer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
padding: $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
span:first-of-type {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta {
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepperRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: $space-3;
|
||||||
|
border-top: 1px solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepperButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
min-width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||||
|
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
import type { BasketLine } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice, formatPriceDelta } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './MenuItemCustomizer.module.scss'
|
||||||
|
|
||||||
|
type MenuItemCustomizerProps = {
|
||||||
|
item: MenuItemWithRecipe
|
||||||
|
// Every basket line for this item, including the plain no-options one —
|
||||||
|
// the stepper below tracks whichever combination is currently checked.
|
||||||
|
lines: Array<BasketLine>
|
||||||
|
onAdd: (menuItemId: number, optionIds: Array<number>) => void
|
||||||
|
onRemove: (menuItemId: number, optionIds: Array<number>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedKey = (optionIds: Array<number>) =>
|
||||||
|
[...optionIds].sort((a, b) => a - b).join(',')
|
||||||
|
|
||||||
|
export const MenuItemCustomizer = ({
|
||||||
|
item,
|
||||||
|
lines,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: MenuItemCustomizerProps) => {
|
||||||
|
const [checkedOptionIds, setCheckedOptionIds] = useState<Array<number>>([])
|
||||||
|
|
||||||
|
const toggleOption = (optionId: number) =>
|
||||||
|
setCheckedOptionIds((prev) =>
|
||||||
|
prev.includes(optionId)
|
||||||
|
? prev.filter((id) => id !== optionId)
|
||||||
|
: [...prev, optionId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// The one stepper on this page always reflects whatever combination of
|
||||||
|
// options is currently checked — checking different boxes just points it
|
||||||
|
// at a different (possibly already-existing) basket line.
|
||||||
|
const currentLine = lines.find(
|
||||||
|
(line) => sortedKey(line.optionIds) === sortedKey(checkedOptionIds),
|
||||||
|
)
|
||||||
|
const quantity = currentLine?.quantity ?? 0
|
||||||
|
const unitPriceCents =
|
||||||
|
item.priceCents +
|
||||||
|
checkedOptionIds.reduce(
|
||||||
|
(sum, id) =>
|
||||||
|
sum + (item.options.find((option) => option.id === id)?.priceDeltaCents ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.customizer}>
|
||||||
|
<h2 className={styles.heading}>Customize</h2>
|
||||||
|
<div className={styles.panel}>
|
||||||
|
{item.options.map((option) => (
|
||||||
|
<label key={option.id} className={styles.option}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedOptionIds.includes(option.id)}
|
||||||
|
onChange={() => toggleOption(option.id)}
|
||||||
|
/>
|
||||||
|
<span>{option.name}</span>
|
||||||
|
{formatPriceDelta(option.priceDeltaCents) && (
|
||||||
|
<span className={styles.delta}>
|
||||||
|
{formatPriceDelta(option.priceDeltaCents)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.stepperRow}>
|
||||||
|
<AnimatedNumber
|
||||||
|
value={unitPriceCents}
|
||||||
|
format={formatPrice}
|
||||||
|
className={styles.price}
|
||||||
|
/>
|
||||||
|
<div className={styles.stepper}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.stepperButton}
|
||||||
|
onClick={() => onRemove(item.id, checkedOptionIds)}
|
||||||
|
disabled={quantity === 0}
|
||||||
|
aria-label={`Remove one ${item.name}`}
|
||||||
|
>
|
||||||
|
<div aria-hidden="true" className={styles.icon}>−</div>
|
||||||
|
</button>
|
||||||
|
<AnimatedNumber value={quantity} className={styles.count} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.stepperButton}
|
||||||
|
onClick={() => onAdd(item.id, checkedOptionIds)}
|
||||||
|
aria-label={`Add one ${item.name}`}
|
||||||
|
>
|
||||||
|
<div aria-hidden="true" className={styles.icon}>+</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergens {
|
||||||
|
margin: $space-1 0 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { MenuItemCustomizer } from '#/components/ordering/MenuItemCustomizer.tsx'
|
||||||
|
import { formatDuration } from '#/components/ordering/MenuList.tsx'
|
||||||
|
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
|
||||||
|
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
import type { BasketLine } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './MenuItemDetail.module.scss'
|
||||||
|
|
||||||
|
type MenuItemDetailProps = {
|
||||||
|
item: MenuItemWithRecipe
|
||||||
|
lines: Array<BasketLine>
|
||||||
|
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItemDetail = ({
|
||||||
|
item,
|
||||||
|
lines,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: MenuItemDetailProps) => {
|
||||||
|
const totalSeconds = item.steps.reduce(
|
||||||
|
(sum, step) => sum + step.estimatedDurationSeconds,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<MenuItemThumbnail
|
||||||
|
name={item.name}
|
||||||
|
size="hero"
|
||||||
|
style={{ viewTransitionName: `menu-item-${item.id}` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.info}>
|
||||||
|
<h1 className={styles.name}>{item.name}</h1>
|
||||||
|
{item.description && (
|
||||||
|
<p className={styles.description}>{item.description}</p>
|
||||||
|
)}
|
||||||
|
<p className={styles.meta}>
|
||||||
|
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||||
|
</p>
|
||||||
|
{item.allergens.length > 0 && (
|
||||||
|
<p className={styles.allergens}>
|
||||||
|
Contains:{' '}
|
||||||
|
{item.allergens.map((allergen) => allergen.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MenuItemCustomizer
|
||||||
|
item={item}
|
||||||
|
lines={lines}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: $radius;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 1.75rem;
|
||||||
|
width: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
import styles from './MenuItemThumbnail.module.scss'
|
||||||
|
|
||||||
|
type MenuItemThumbnailProps = {
|
||||||
|
name: string
|
||||||
|
size?: 'small' | 'hero'
|
||||||
|
style?: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const UtensilsIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M7 3v7a2 2 0 0 0 2 2v9" />
|
||||||
|
<path d="M7 3v4M11 3v4" />
|
||||||
|
<path d="M17 3c-1.5 0-2.5 2-2.5 5s1 5 1.5 5v9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const photoByKeyword: Array<{ keyword: string; src: string }> = [
|
||||||
|
{ keyword: 'pizza', src: '/pizza.png' },
|
||||||
|
{ keyword: 'burger', src: '/burger.png' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Falls back to a simple icon for any menu item that isn't Pizza or Burger,
|
||||||
|
// since there's no photo for it yet.
|
||||||
|
const photoFor = (name: string) => {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
return photoByKeyword.find((entry) => lower.includes(entry.keyword))?.src
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItemThumbnail = ({
|
||||||
|
name,
|
||||||
|
size = 'small',
|
||||||
|
style,
|
||||||
|
}: MenuItemThumbnailProps) => {
|
||||||
|
const src = photoFor(name)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.thumbnail} ${size === 'hero' ? styles.hero : ''}`}
|
||||||
|
style={style}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt="" className={styles.photo} />
|
||||||
|
) : (
|
||||||
|
<UtensilsIcon />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-3;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:is(a, button) {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover .name {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergens {
|
||||||
|
margin: $space-1 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepperButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
min-width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
|
||||||
|
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||||
|
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
import type { BasketLine } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './MenuList.module.scss'
|
||||||
|
|
||||||
|
type MenuListProps = {
|
||||||
|
menu: Array<MenuItemWithRecipe>
|
||||||
|
lines: Array<BasketLine>
|
||||||
|
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDuration = (seconds: number) => {
|
||||||
|
const minutes = Math.round(seconds / 60)
|
||||||
|
return minutes <= 1 ? '1 min' : `${minutes} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{menu.map((item) => {
|
||||||
|
const defaultLine = lines.find(
|
||||||
|
(line) => line.menuItemId === item.id && line.optionIds.length === 0,
|
||||||
|
)
|
||||||
|
const hasOptions = item.options.length > 0
|
||||||
|
const totalSeconds = item.steps.reduce(
|
||||||
|
(sum, step) => sum + step.estimatedDurationSeconds,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const info = (
|
||||||
|
<>
|
||||||
|
<MenuItemThumbnail
|
||||||
|
name={item.name}
|
||||||
|
style={{ viewTransitionName: `menu-item-${item.id}` }}
|
||||||
|
/>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<h3 className={styles.name}>{item.name}</h3>
|
||||||
|
{item.description && (
|
||||||
|
<p className={styles.description}>{item.description}</p>
|
||||||
|
)}
|
||||||
|
<p className={styles.meta}>
|
||||||
|
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||||
|
</p>
|
||||||
|
{item.allergens.length > 0 && (
|
||||||
|
<p className={styles.allergens}>
|
||||||
|
Contains:{' '}
|
||||||
|
{item.allergens.map((allergen) => allergen.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id} className={styles.item}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
{hasOptions ? (
|
||||||
|
<Link
|
||||||
|
to="/items/$menuItemId"
|
||||||
|
params={{ menuItemId: String(item.id) }}
|
||||||
|
className={styles.main}
|
||||||
|
>
|
||||||
|
{info}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={styles.main}>{info}</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.quantity}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.stepperButton}
|
||||||
|
onClick={() => onRemove(item.id)}
|
||||||
|
disabled={(defaultLine?.quantity ?? 0) === 0}
|
||||||
|
aria-label={`Remove one ${item.name}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">−</span>
|
||||||
|
</button>
|
||||||
|
<AnimatedNumber
|
||||||
|
value={defaultLine?.quantity ?? 0}
|
||||||
|
className={styles.count}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.stepperButton}
|
||||||
|
onClick={() => onAdd(item.id)}
|
||||||
|
aria-label={`Add one ${item.name}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0 0 $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderCustomer {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderItems {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderMeta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import type { AdminOrderSummary } from '#/domain/ordering.ts'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './AdminOrdersView.module.scss'
|
||||||
|
|
||||||
|
type AdminOrdersViewProps = {
|
||||||
|
orders: Array<AdminOrderSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatItems = (items: AdminOrderSummary['items']) =>
|
||||||
|
items
|
||||||
|
.map(({ menuItemName, quantity }) =>
|
||||||
|
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
|
||||||
|
)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
const formatCustomer = (customer: AdminOrderSummary['customer']) =>
|
||||||
|
customer.type === 'account'
|
||||||
|
? `Account ${customer.customerId}`
|
||||||
|
: `${customer.name} (guest) · ${customer.contact}`
|
||||||
|
|
||||||
|
export const AdminOrdersView = ({ orders }: AdminOrdersViewProps) => (
|
||||||
|
<section>
|
||||||
|
<h1 className={styles.heading}>All orders</h1>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className={styles.empty}>No orders have been placed yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.orders}>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<li key={order.id}>
|
||||||
|
<Link
|
||||||
|
to="/admin/orders/$orderId"
|
||||||
|
params={{ orderId: String(order.id) }}
|
||||||
|
className={styles.order}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
<div className={styles.orderHeader}>
|
||||||
|
<span>Order #{order.id}</span>
|
||||||
|
<span className={styles.orderStatus}>{order.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.orderCustomer}>
|
||||||
|
{formatCustomer(order.customer)}
|
||||||
|
</p>
|
||||||
|
<p className={styles.orderItems}>{formatItems(order.items)}</p>
|
||||||
|
<p className={styles.orderMeta}>
|
||||||
|
{order.createdAt.toLocaleString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}{' '}
|
||||||
|
· {formatPrice(order.totalCents)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.eta {
|
||||||
|
margin: 0 0 $space-2;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueText {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||||
|
|
||||||
|
import { formatEtaParts } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './EtaMessage.module.scss'
|
||||||
|
|
||||||
|
export const EtaMessage = ({ etaSeconds }: { etaSeconds: number }) => {
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
const { prefix, value } = formatEtaParts(etaSeconds)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className={styles.eta}>
|
||||||
|
{prefix}{' '}
|
||||||
|
<span className={styles.value}>
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
<motion.span
|
||||||
|
key={value}
|
||||||
|
className={styles.valueText}
|
||||||
|
initial={reduceMotion ? false : { y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={reduceMotion ? { opacity: 0 } : { y: '-100%', opacity: 0 }}
|
||||||
|
transition={{ duration: reduceMotion ? 0 : 0.35, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
color: $color-success;
|
||||||
|
|
||||||
|
&[data-variant='cancelled'] {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { motion, useReducedMotion } from 'framer-motion'
|
||||||
|
|
||||||
|
import styles from './ItemStatusBanner.module.scss'
|
||||||
|
|
||||||
|
export type ItemStatusBannerVariant = 'ready' | 'pickedUp' | 'cancelled'
|
||||||
|
|
||||||
|
const iconPaths = {
|
||||||
|
ready: ['M7.5 12.5l3 3 6-6.5'],
|
||||||
|
pickedUp: ['M7.5 12.5l3 3 6-6.5'],
|
||||||
|
cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'],
|
||||||
|
} satisfies Record<ItemStatusBannerVariant, Array<string>>
|
||||||
|
|
||||||
|
type ItemStatusBannerProps = {
|
||||||
|
variant: ItemStatusBannerVariant
|
||||||
|
label: string
|
||||||
|
detail?: string
|
||||||
|
animateIn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemStatusBanner = ({
|
||||||
|
variant,
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
|
animateIn,
|
||||||
|
}: ItemStatusBannerProps) => {
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
const skipAnimation = !animateIn || reduceMotion
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={styles.banner}
|
||||||
|
data-variant={variant}
|
||||||
|
role="status"
|
||||||
|
initial={skipAnimation ? false : { opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<motion.circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
initial={skipAnimation ? false : { pathLength: 0, rotate: -90 }}
|
||||||
|
animate={{ pathLength: 1, rotate: -90 }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
{iconPaths[variant].map((d, index) => (
|
||||||
|
<motion.path
|
||||||
|
key={d}
|
||||||
|
d={d}
|
||||||
|
initial={skipAnimation ? false : { pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'easeOut',
|
||||||
|
delay: 0.45 + index * 0.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<span className={styles.text}>
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
{detail && <span className={styles.detail}>{detail}</span>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderStatus {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
color: $color-text-muted;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderDate {
|
||||||
|
margin: 0 0 $space-4;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulateButton {
|
||||||
|
margin: 0 0 $space-4;
|
||||||
|
padding: $space-1 $space-3;
|
||||||
|
border: 1px dashed $color-text-muted;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: none;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuButton {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: (-$space-2) (-$space-2) (-$space-2) $space-2;
|
||||||
|
padding: $space-2;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: none;
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
position-area: bottom span-left;
|
||||||
|
position-try-fallbacks: flip-block;
|
||||||
|
inset: auto;
|
||||||
|
margin: $space-1 0;
|
||||||
|
padding: $space-1;
|
||||||
|
min-width: 160px;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-text;
|
||||||
|
font-weight: 400;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
width: 100%;
|
||||||
|
padding: $space-2 $space-3;
|
||||||
|
border: none;
|
||||||
|
border-radius: calc(#{$radius} - 2px);
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $color-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemOptions {
|
||||||
|
margin: 0 0 $space-2;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The negative margin + matching padding leaves room for the in-progress
|
||||||
|
// dot's sonar ping, which the collapse animation's overflow would clip.
|
||||||
|
.timelineWrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: (-$space-2);
|
||||||
|
padding: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $space-2;
|
||||||
|
margin: 0 0 $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
margin: 0 0 $space-2;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepConnector {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
bottom: -$space-3;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
background: $color-border;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepConnectorFill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: $color-success;
|
||||||
|
transform-origin: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:last-child .stepConnector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepMeta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step[data-status='done'] {
|
||||||
|
opacity: 0.3;
|
||||||
|
|
||||||
|
.stepDot {
|
||||||
|
border-color: $color-success;
|
||||||
|
background: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepLabel {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step[data-status='in_progress'] {
|
||||||
|
.stepDot {
|
||||||
|
border-color: $color-success;
|
||||||
|
background: $color-success;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation: dotPopIn 0.4s ease-out 0.6s backwards;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-success;
|
||||||
|
opacity: 0;
|
||||||
|
animation: sonarPing 1.6s ease-out 1s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animates the box size instead of transform: scale, which Chrome
|
||||||
|
// rasterizes at a low resolution mid-animation and renders the tiny dot square.
|
||||||
|
@keyframes dotPopIn {
|
||||||
|
0% {
|
||||||
|
border-color: $color-border;
|
||||||
|
background: $color-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sonarPing {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(2.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step[data-status='pending'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $space-2;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickupButton {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
border-color: $color-text;
|
||||||
|
background: $color-text;
|
||||||
|
color: $color-background;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-text;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
flex-basis: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: $space-2;
|
||||||
|
margin-bottom: $space-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionIcon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shackle lifts above the 24-unit viewBox when unlocked.
|
||||||
|
.lockIcon {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { HoldButton } from '#/components/ui/HoldButton.tsx'
|
||||||
|
import type { OrderStatus } from '#/db/schema.ts'
|
||||||
|
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
||||||
|
import { useBasket } from '#/lib/basket.tsx'
|
||||||
|
import { formatCountdown } from '#/lib/format.ts'
|
||||||
|
import {
|
||||||
|
advanceSimulatedKitchen,
|
||||||
|
cancelOrderItem,
|
||||||
|
closePickupBox,
|
||||||
|
getOrderStatus,
|
||||||
|
openPickupBox,
|
||||||
|
} from '#/server/orders.ts'
|
||||||
|
|
||||||
|
import styles from './OrderStatusView.module.scss'
|
||||||
|
import { EtaMessage } from './EtaMessage.tsx'
|
||||||
|
import { ItemStatusBanner } from './ItemStatusBanner.tsx'
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
const isSettled = (item: OrderItemStatusView) =>
|
||||||
|
item.status === 'ready' ||
|
||||||
|
item.status === 'picked_up' ||
|
||||||
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'discarded'
|
||||||
|
|
||||||
|
// `animationKey` groups variants that should read as one continuous banner:
|
||||||
|
// going from ready to picked up swaps the label without replaying the check.
|
||||||
|
// `now` is null until the page hydrates, so the server and first client
|
||||||
|
// render agree and the countdown only appears once it can tick.
|
||||||
|
const getItemBanner = (item: OrderItemStatusView, now: number | null) => {
|
||||||
|
if (item.status === 'ready') {
|
||||||
|
return {
|
||||||
|
animationKey: 'done',
|
||||||
|
variant: 'ready',
|
||||||
|
label: 'Ready for pickup',
|
||||||
|
detail: !item.pickupBox
|
||||||
|
? 'Waiting for a free pickup box'
|
||||||
|
: item.pickupDeadline && now !== null
|
||||||
|
? `Pickup box ${item.pickupBox.number} · ${formatCountdown(item.pickupDeadline.getTime() - now)} left`
|
||||||
|
: `Pickup box ${item.pickupBox.number}`,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
if (item.status === 'discarded') {
|
||||||
|
return {
|
||||||
|
animationKey: 'discarded',
|
||||||
|
variant: 'cancelled',
|
||||||
|
label: 'Not picked up in time',
|
||||||
|
detail: `${item.pickupBox ? `Pickup box ${item.pickupBox.number}` : 'The pickup box'} was emptied after 15 minutes. This item can't be refunded.`,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
if (item.status === 'picked_up') {
|
||||||
|
return {
|
||||||
|
animationKey: 'done',
|
||||||
|
variant: 'pickedUp',
|
||||||
|
label: 'Picked up',
|
||||||
|
detail: item.pickupBox
|
||||||
|
? `Pickup box ${item.pickupBox.number}`
|
||||||
|
: undefined,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
if (item.status === 'cancelled') {
|
||||||
|
return item.cancelledBy === 'kitchen'
|
||||||
|
? ({
|
||||||
|
animationKey: 'cancelled',
|
||||||
|
variant: 'cancelled',
|
||||||
|
label: 'Cancelled by the kitchen',
|
||||||
|
detail:
|
||||||
|
"Something went wrong on our end, so we've refunded this item.",
|
||||||
|
} as const)
|
||||||
|
: ({
|
||||||
|
animationKey: 'cancelled',
|
||||||
|
variant: 'cancelled',
|
||||||
|
label: 'Cancelled',
|
||||||
|
detail: "You've been refunded for this item.",
|
||||||
|
} as const)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannerKey = (item: OrderItemStatusView) => {
|
||||||
|
const banner = getItemBanner(item, null)
|
||||||
|
return banner ? `${item.id}:${banner.animationKey}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackArrowIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.backArrow}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="M11 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const actionIconPaths = {
|
||||||
|
buyAgain: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5'],
|
||||||
|
help: [
|
||||||
|
'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
|
||||||
|
'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3',
|
||||||
|
'M12 17h.01',
|
||||||
|
],
|
||||||
|
cancel: ['M18 6 6 18', 'M6 6l12 12'],
|
||||||
|
more: ['M12 12h.01', 'M19 12h.01', 'M5 12h.01'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
|
||||||
|
<svg
|
||||||
|
className={styles.actionIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{actionIconPaths[name].map((d) => (
|
||||||
|
<path key={d} d={d} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CLOSED_BOX_VISIBLE_MS = 5 * 60 * 1000
|
||||||
|
// Same commands in both so the shackle morphs: unlocked, the left leg
|
||||||
|
// stretches up from the body and the right leg shrinks to a stub.
|
||||||
|
const LOCKED_SHACKLE = 'M7 11V7a5 5 0 0 1 10 0v4'
|
||||||
|
const UNLOCKED_SHACKLE = 'M7 11V4a5 5 0 0 1 10 0v1'
|
||||||
|
// Overshoots past the target, so the shackle springs open and snaps shut.
|
||||||
|
const SHACKLE_EASE = [0.34, 1.8, 0.64, 1] as const
|
||||||
|
|
||||||
|
const pickupButtonLabel = (
|
||||||
|
box: { number: number; isOpen: boolean },
|
||||||
|
closed: boolean,
|
||||||
|
) => {
|
||||||
|
if (closed) return `Box ${box.number} closed`
|
||||||
|
return box.isOpen
|
||||||
|
? `Hold to close box ${box.number}`
|
||||||
|
: `Hold to open box ${box.number}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const LockIcon = ({ open }: { open: boolean }) => {
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
return (
|
||||||
|
<motion.svg
|
||||||
|
className={`${styles.actionIcon} ${styles.lockIcon}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ originY: 1 }}
|
||||||
|
initial={false}
|
||||||
|
animate={
|
||||||
|
open
|
||||||
|
? {
|
||||||
|
y: [0, -4, 1, -1.5, 0],
|
||||||
|
scaleX: [1, 0.94, 1.06, 0.98, 1],
|
||||||
|
scaleY: [1, 1.1, 0.92, 1.03, 1],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
y: [0, 2, -3.5, 1, -1, 0],
|
||||||
|
scaleX: [1, 1.14, 0.94, 1.04, 0.99, 1],
|
||||||
|
scaleY: [1, 0.8, 1.1, 0.96, 1.02, 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
reduceMotion ? { duration: 0 } : { duration: 0.65, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" />
|
||||||
|
<motion.path
|
||||||
|
initial={false}
|
||||||
|
animate={{ d: open ? UNLOCKED_SHACKLE : LOCKED_SHACKLE }}
|
||||||
|
transition={
|
||||||
|
reduceMotion ? { duration: 0 } : { duration: 0.4, ease: SHACKLE_EASE }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</motion.svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepConnector = ({ filled }: { filled: boolean }) => {
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
return (
|
||||||
|
<span className={styles.stepConnector}>
|
||||||
|
<motion.span
|
||||||
|
className={styles.stepConnectorFill}
|
||||||
|
initial={false}
|
||||||
|
animate={{ scaleY: filled ? 1 : 0 }}
|
||||||
|
transition={
|
||||||
|
reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderStatusResult = {
|
||||||
|
id: number
|
||||||
|
status: OrderStatus
|
||||||
|
createdAt: Date
|
||||||
|
items: Array<OrderItemStatusView>
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderStatusViewProps = {
|
||||||
|
status: OrderStatusResult
|
||||||
|
guestAccessToken: string | undefined
|
||||||
|
backTo?: '/orders' | '/admin/orders'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrderStatusView = ({
|
||||||
|
status,
|
||||||
|
guestAccessToken,
|
||||||
|
backTo = '/orders',
|
||||||
|
}: OrderStatusViewProps) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const basket = useBasket()
|
||||||
|
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [simulating, setSimulating] = useState(false)
|
||||||
|
// Only banners that appear while the page is open animate in; ones
|
||||||
|
// already showing on load render straight away.
|
||||||
|
const [initialBannerKeys] = useState(
|
||||||
|
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
|
||||||
|
)
|
||||||
|
// Ticks every second while a pickup countdown runs; otherwise only wakes
|
||||||
|
// when a closed box's button (kept for a while after pickup) is due to go.
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
useEffect(() => setHydrated(true), [])
|
||||||
|
const hasPickupCountdown = status.items.some(
|
||||||
|
(item) => item.pickupDeadline !== null,
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasPickupCountdown) return
|
||||||
|
setNow(Date.now())
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [hasPickupCountdown])
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasPickupCountdown) return
|
||||||
|
const hideAt = status.items
|
||||||
|
.flatMap((item) =>
|
||||||
|
item.pickedUpAt
|
||||||
|
? [item.pickedUpAt.getTime() + CLOSED_BOX_VISIBLE_MS]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.filter((time) => time > now)
|
||||||
|
if (hideAt.length === 0) return
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => setNow(Date.now()),
|
||||||
|
Math.min(...hideAt) - Date.now(),
|
||||||
|
)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [status.items, now, hasPickupCountdown])
|
||||||
|
// Once a countdown runs out, reload so the server can empty the box and the
|
||||||
|
// item shows as discarded. Only once per item: an open box isn't emptied.
|
||||||
|
const refreshedExpiredItemIds = useRef(new Set<number>())
|
||||||
|
useEffect(() => {
|
||||||
|
const newlyExpired = status.items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.pickupDeadline !== null &&
|
||||||
|
item.pickupDeadline.getTime() <= now &&
|
||||||
|
!refreshedExpiredItemIds.current.has(item.id),
|
||||||
|
)
|
||||||
|
if (newlyExpired.length === 0) return
|
||||||
|
for (const item of newlyExpired)
|
||||||
|
refreshedExpiredItemIds.current.add(item.id)
|
||||||
|
void router.invalidate()
|
||||||
|
}, [status.items, now, router])
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
const collapseTransition = reduceMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { duration: 0.4, ease: 'easeInOut' as const }
|
||||||
|
|
||||||
|
const buyAgain = (item: OrderItemStatusView) => {
|
||||||
|
basket.add(item.menuItemId, item.buyAgainOptionIds)
|
||||||
|
void navigate({ to: '/basket' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAction = async (
|
||||||
|
orderItemId: number,
|
||||||
|
action: () => Promise<void>,
|
||||||
|
) => {
|
||||||
|
setPendingItemId(orderItemId)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
await router.invalidate()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong.')
|
||||||
|
} finally {
|
||||||
|
setPendingItemId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapses to nothing on both axes, so the row closes the gap whether the
|
||||||
|
// button sits inline (desktop) or on its own full-width line (mobile); the
|
||||||
|
// negative margins cancel the flex gap it leaves behind.
|
||||||
|
const pickupButtonExit = reduceMotion
|
||||||
|
? { opacity: 0, transition: { duration: 0 } }
|
||||||
|
: {
|
||||||
|
opacity: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
marginTop: 0,
|
||||||
|
marginRight: -8,
|
||||||
|
marginBottom: -8,
|
||||||
|
transition: { duration: 0.3, ease: 'easeInOut' as const },
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePickupBox = (item: OrderItemStatusView) => {
|
||||||
|
const toggle = item.pickupBox?.isOpen ? closePickupBox : openPickupBox
|
||||||
|
return toggle({ data: { orderItemId: item.id, guestAccessToken } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const runKitchenSimulation = async () => {
|
||||||
|
setSimulating(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
await advanceSimulatedKitchen()
|
||||||
|
const fresh = await getOrderStatus({
|
||||||
|
data: { orderId: status.id, guestAccessToken },
|
||||||
|
})
|
||||||
|
await router.invalidate()
|
||||||
|
if (
|
||||||
|
!fresh.items.some(
|
||||||
|
(item) => item.status === 'placed' || item.status === 'paid',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await sleep(1000)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong.')
|
||||||
|
} finally {
|
||||||
|
setSimulating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasItemsToSimulate = status.items.some(
|
||||||
|
(item) => item.status === 'placed' || item.status === 'paid',
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.page}>
|
||||||
|
<Link
|
||||||
|
to={backTo}
|
||||||
|
className={styles.backLink}
|
||||||
|
viewTransition={backTo !== '/admin/orders'}
|
||||||
|
>
|
||||||
|
<BackArrowIcon />
|
||||||
|
{backTo === '/admin/orders' ? 'Back to all orders' : 'Back to orders'}
|
||||||
|
</Link>
|
||||||
|
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
||||||
|
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||||
|
<p className={styles.orderStatus}>Status: {status.status}</p>
|
||||||
|
<p className={styles.orderDate}>
|
||||||
|
Placed{' '}
|
||||||
|
{status.createdAt.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
{' at '}
|
||||||
|
{status.createdAt.toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{import.meta.env.DEV && hasItemsToSimulate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.simulateButton}
|
||||||
|
disabled={simulating}
|
||||||
|
onClick={() => void runKitchenSimulation()}
|
||||||
|
>
|
||||||
|
{simulating ? 'Simulating kitchen…' : 'Simulate kitchen (dev)'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
<ul className={styles.items}>
|
||||||
|
{status.items.map((item) => {
|
||||||
|
const banner = getItemBanner(item, hydrated ? now : null)
|
||||||
|
const boxClosed = item.status !== 'ready'
|
||||||
|
const pickupBox =
|
||||||
|
item.status === 'ready' ||
|
||||||
|
(item.pickedUpAt !== null &&
|
||||||
|
now - item.pickedUpAt.getTime() < CLOSED_BOX_VISIBLE_MS)
|
||||||
|
? item.pickupBox
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<li key={item.id} className={styles.item}>
|
||||||
|
<div className={styles.itemHeader}>
|
||||||
|
<span className={styles.itemName}>{item.menuItemName}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.menuButton}
|
||||||
|
aria-label={`More actions for ${item.menuItemName}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
popoverTarget={`item-menu-${item.id}`}
|
||||||
|
style={{ anchorName: `--item-menu-${item.id}` }}
|
||||||
|
>
|
||||||
|
<ActionIcon name="more" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id={`item-menu-${item.id}`}
|
||||||
|
popover="auto"
|
||||||
|
role="menu"
|
||||||
|
className={styles.menu}
|
||||||
|
style={{ positionAnchor: `--item-menu-${item.id}` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuItem}
|
||||||
|
disabled={pendingItemId === item.id}
|
||||||
|
onClick={() => buyAgain(item)}
|
||||||
|
>
|
||||||
|
<ActionIcon name="buyAgain" />
|
||||||
|
Buy again
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuItem}
|
||||||
|
to="/help"
|
||||||
|
search={{
|
||||||
|
orderId: status.id,
|
||||||
|
item: item.menuItemName,
|
||||||
|
status: item.status,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActionIcon name="help" />
|
||||||
|
Get help
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.options.length > 0 && (
|
||||||
|
<p className={styles.itemOptions}>
|
||||||
|
{item.options.map((option) => option.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!isSettled(item) && (
|
||||||
|
<motion.div
|
||||||
|
key="timeline"
|
||||||
|
className={styles.timelineWrapper}
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={collapseTransition}
|
||||||
|
>
|
||||||
|
<ol className={styles.timeline}>
|
||||||
|
{item.steps.map((step) => (
|
||||||
|
<li
|
||||||
|
key={step.sequence}
|
||||||
|
className={styles.step}
|
||||||
|
data-status={step.status}
|
||||||
|
aria-current={
|
||||||
|
step.status === 'in_progress' ? 'step' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={styles.stepMarker}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className={styles.stepDot} />
|
||||||
|
<StepConnector filled={step.status === 'done'} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.stepContent}>
|
||||||
|
<span className={styles.stepLabel}>
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
{step.status === 'queued' &&
|
||||||
|
step.queuePosition !== null && (
|
||||||
|
<span className={styles.stepMeta}>
|
||||||
|
#{step.queuePosition} in line
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
{banner && (
|
||||||
|
<div className={styles.bannerRow}>
|
||||||
|
<ItemStatusBanner
|
||||||
|
key={banner.animationKey}
|
||||||
|
variant={banner.variant}
|
||||||
|
label={banner.label}
|
||||||
|
detail={'detail' in banner ? banner.detail : undefined}
|
||||||
|
animateIn={
|
||||||
|
!initialBannerKeys.has(
|
||||||
|
`${item.id}:${banner.animationKey}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.etaSeconds !== null && (
|
||||||
|
<EtaMessage etaSeconds={item.etaSeconds} />
|
||||||
|
)}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{pickupBox && (
|
||||||
|
<HoldButton
|
||||||
|
key="pickupBox"
|
||||||
|
className={styles.pickupButton}
|
||||||
|
disabled={pendingItemId === item.id || boxClosed}
|
||||||
|
onHold={() =>
|
||||||
|
void runAction(item.id, () => togglePickupBox(item))
|
||||||
|
}
|
||||||
|
exit={pickupButtonExit}
|
||||||
|
>
|
||||||
|
<LockIcon open={pickupBox.isOpen} />
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={pickupButtonLabel(pickupBox, boxClosed)}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: reduceMotion ? 0 : 0.18 }}
|
||||||
|
>
|
||||||
|
{pickupButtonLabel(pickupBox, boxClosed)}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</HoldButton>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
{item.cancellable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelButton}
|
||||||
|
disabled={pendingItemId === item.id}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction(item.id, () =>
|
||||||
|
cancelOrderItem({
|
||||||
|
data: { orderItemId: item.id, guestAccessToken },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActionIcon name="cancel" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0 0 $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes empty-state-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation: empty-state-enter 0.32s cubic-bezier(0.2, 0, 0, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
margin-bottom: $space-2;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
margin: 0 0 $space-3;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 2px 8px color-mix(in srgb, $color-primary 35%, transparent);
|
||||||
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px color-mix(in srgb, $color-primary 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 8px color-mix(in srgb, $color-primary 35%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCtaArrow {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCta:hover .emptyCtaArrow {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
||||||
|
opacity 0.28s ease,
|
||||||
|
transform 0.28s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderExiting .order {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&[data-progress='ready'],
|
||||||
|
&[data-progress='picked_up'] {
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-progress='cancelled'],
|
||||||
|
&[data-progress='discarded'] {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderItems {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderMeta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
||||||
|
import { formatPrice, formatStatus } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './OrdersListView.module.scss'
|
||||||
|
|
||||||
|
type OrdersListViewProps = {
|
||||||
|
orders: Array<CustomerOrderSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must match the CSS transition duration on .order in
|
||||||
|
// OrdersListView.module.scss.
|
||||||
|
const EXIT_DURATION_MS = 280
|
||||||
|
|
||||||
|
const OrdersIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9 3h6l1 3H8l1-3Z" />
|
||||||
|
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
|
||||||
|
<path d="M9 11h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatItems = (items: CustomerOrderSummary['items']) =>
|
||||||
|
items
|
||||||
|
.map(({ menuItemName, quantity }) =>
|
||||||
|
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
|
||||||
|
)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
export const OrdersListView = ({ orders }: OrdersListViewProps) => {
|
||||||
|
const [displayedOrders, setDisplayedOrders] = useState(orders)
|
||||||
|
const [isExiting, setIsExiting] = useState(false)
|
||||||
|
const hadOrders = useRef(orders.length > 0)
|
||||||
|
|
||||||
|
// Orders can go from populated to empty in one prop update (e.g. signing
|
||||||
|
// out clears them) — keep the previous list on screen just long enough to
|
||||||
|
// fade it out instead of popping straight to the empty state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (orders.length === 0 && hadOrders.current) {
|
||||||
|
hadOrders.current = false
|
||||||
|
setIsExiting(true)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setDisplayedOrders([])
|
||||||
|
setIsExiting(false)
|
||||||
|
}, EXIT_DURATION_MS)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
hadOrders.current = orders.length > 0
|
||||||
|
setDisplayedOrders(orders)
|
||||||
|
}, [orders])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.page}>
|
||||||
|
<h1 className={styles.heading}>Your orders</h1>
|
||||||
|
{displayedOrders.length === 0 ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<OrdersIcon />
|
||||||
|
<p className={styles.emptyTitle}>No orders yet</p>
|
||||||
|
<p className={styles.emptyText}>
|
||||||
|
Your placed orders will show up here.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className={styles.emptyCta}>
|
||||||
|
Browse the menu
|
||||||
|
<span className={styles.emptyCtaArrow} aria-hidden="true">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.orders}>
|
||||||
|
{displayedOrders.map((order) => (
|
||||||
|
<li
|
||||||
|
key={order.id}
|
||||||
|
className={isExiting ? styles.orderExiting : undefined}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/orders/$orderId"
|
||||||
|
params={{ orderId: String(order.id) }}
|
||||||
|
search={
|
||||||
|
order.guestAccessToken
|
||||||
|
? { token: order.guestAccessToken }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={styles.order}
|
||||||
|
style={{ viewTransitionName: `order-card-${order.id}` }}
|
||||||
|
>
|
||||||
|
<div className={styles.orderHeader}>
|
||||||
|
<span className={styles.orderName}>Order #{order.id}</span>
|
||||||
|
<span
|
||||||
|
className={styles.orderStatus}
|
||||||
|
data-progress={order.progress}
|
||||||
|
>
|
||||||
|
{formatStatus(order.progress)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.orderItems}>{formatItems(order.items)}</p>
|
||||||
|
<p className={styles.orderMeta}>
|
||||||
|
{order.createdAt.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}{' '}
|
||||||
|
· {formatPrice(order.totalCents)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { animate, useReducedMotion } from 'framer-motion'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type AnimatedCounterProps = {
|
||||||
|
value: number
|
||||||
|
format?: (value: number) => string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweens the displayed number from its previous value to the new one,
|
||||||
|
// rather than swapping straight to it, so a price change reads as counting
|
||||||
|
// up or down. `displayValueRef` tracks the in-flight value so a change that
|
||||||
|
// interrupts a running tween continues from wherever it currently is
|
||||||
|
// instead of jumping back to the last committed value.
|
||||||
|
export const AnimatedCounter = ({
|
||||||
|
value,
|
||||||
|
format = String,
|
||||||
|
className,
|
||||||
|
}: AnimatedCounterProps) => {
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
const [displayValue, setDisplayValue] = useState(value)
|
||||||
|
const displayValueRef = useRef(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduceMotion) {
|
||||||
|
displayValueRef.current = value
|
||||||
|
setDisplayValue(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = animate(displayValueRef.current, value, {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'easeOut',
|
||||||
|
onUpdate: (latest) => {
|
||||||
|
displayValueRef.current = latest
|
||||||
|
setDisplayValue(latest)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controls.stop()
|
||||||
|
}, [value, reduceMotion])
|
||||||
|
|
||||||
|
return <span className={className}>{format(Math.round(displayValue))}</span>
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
justify-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digit {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import styles from './AnimatedNumber.module.scss'
|
||||||
|
|
||||||
|
type AnimatedNumberProps = {
|
||||||
|
value: number
|
||||||
|
format?: (value: number) => string
|
||||||
|
align?: 'center' | 'start'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
initial: (direction: number) => ({ x: direction * 12, opacity: 0 }),
|
||||||
|
animate: { x: 0, opacity: 1 },
|
||||||
|
exit: (direction: number) => ({ x: direction * -12, opacity: 0 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quantity stepper's count slides leftward when incrementing (the new
|
||||||
|
// digit enters from the right, the old one exits to the left) and
|
||||||
|
// rightward when decrementing.
|
||||||
|
//
|
||||||
|
// `direction` is derived from a prop change during render (the React-docs
|
||||||
|
// "adjusting state when a prop changes" pattern) rather than mutating a ref
|
||||||
|
// in the render body — mutating a ref here would already reflect the new
|
||||||
|
// value by the time `exit`'s "custom" is resolved for the outgoing digit,
|
||||||
|
// so every transition — increment or decrement — read as "forward".
|
||||||
|
export const AnimatedNumber = ({
|
||||||
|
value,
|
||||||
|
format = String,
|
||||||
|
align = 'center',
|
||||||
|
className,
|
||||||
|
}: AnimatedNumberProps) => {
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
const [previousValue, setPreviousValue] = useState(value)
|
||||||
|
const [direction, setDirection] = useState(1)
|
||||||
|
|
||||||
|
if (value !== previousValue) {
|
||||||
|
setDirection(value > previousValue ? 1 : -1)
|
||||||
|
setPreviousValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
return <span className={className}>{format(value)}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${styles.wrapper} ${className ?? ''}`}
|
||||||
|
style={{ justifyItems: align }}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
|
||||||
|
<motion.span
|
||||||
|
key={value}
|
||||||
|
custom={direction}
|
||||||
|
variants={variants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
|
className={styles.digit}
|
||||||
|
>
|
||||||
|
{format(value)}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: $space-5;
|
||||||
|
padding: $space-4 $space-3;
|
||||||
|
border-top: 1px solid $color-border;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $space-3;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-text-muted;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { Logo } from '#/components/ui/Logo.tsx'
|
||||||
|
|
||||||
|
import styles from './Footer.module.scss'
|
||||||
|
|
||||||
|
type FooterProps = {
|
||||||
|
brandName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Footer = ({ brandName }: FooterProps) => (
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Link to="/" className={styles.brand}>
|
||||||
|
<Logo name={brandName} />
|
||||||
|
</Link>
|
||||||
|
<nav className={styles.links} aria-label="Footer">
|
||||||
|
<Link to="/">Menu</Link>
|
||||||
|
<Link to="/basket">Basket</Link>
|
||||||
|
<Link to="/orders">Your orders</Link>
|
||||||
|
<Link to="/help">Help</Link>
|
||||||
|
</nav>
|
||||||
|
<p className={styles.copyright}>
|
||||||
|
© {new Date().getFullYear()} {brandName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.holdButton {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.2;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { HTMLMotionProps } from 'framer-motion'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import styles from './HoldButton.module.scss'
|
||||||
|
|
||||||
|
type HoldButtonProps = Omit<
|
||||||
|
HTMLMotionProps<'button'>,
|
||||||
|
'onClick' | 'children' | 'type'
|
||||||
|
> & {
|
||||||
|
onHold: () => void
|
||||||
|
holdMs?: number
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHoldKey = (event: KeyboardEvent) =>
|
||||||
|
event.key === ' ' || event.key === 'Enter'
|
||||||
|
|
||||||
|
// Fires only after being held down for `holdMs`; letting go early, or
|
||||||
|
// sliding off the button, cancels. Space and Enter work the same way.
|
||||||
|
export const HoldButton = ({
|
||||||
|
onHold,
|
||||||
|
holdMs = 800,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: HoldButtonProps) => {
|
||||||
|
const [holding, setHolding] = useState(false)
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (!disabled) setHolding(true)
|
||||||
|
}
|
||||||
|
const stop = () => setHolding(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
{...rest}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className={[styles.holdButton, className].filter(Boolean).join(' ')}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button === 0) start()
|
||||||
|
}}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerLeave={stop}
|
||||||
|
onPointerCancel={stop}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!isHoldKey(event)) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (!event.repeat) start()
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (isHoldKey(event)) stop()
|
||||||
|
}}
|
||||||
|
onBlur={stop}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className={styles.fill}
|
||||||
|
aria-hidden="true"
|
||||||
|
initial={false}
|
||||||
|
animate={{ scaleX: holding ? 1 : 0 }}
|
||||||
|
transition={
|
||||||
|
holding
|
||||||
|
? { duration: holdMs / 1000, ease: 'linear' }
|
||||||
|
: { duration: 0.2, ease: 'easeOut' }
|
||||||
|
}
|
||||||
|
onAnimationComplete={() => {
|
||||||
|
if (!holding) return
|
||||||
|
setHolding(false)
|
||||||
|
onHold()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={styles.content}>{children}</span>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.logo {
|
||||||
|
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faded {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostIcon {
|
||||||
|
display: inline-block;
|
||||||
|
height: 0.78em;
|
||||||
|
width: 0.78em;
|
||||||
|
vertical-align: -0.05em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useId } from 'react'
|
||||||
|
|
||||||
|
import styles from './Logo.module.scss'
|
||||||
|
|
||||||
|
type LogoProps = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GhostIcon = () => {
|
||||||
|
const eyeMaskId = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={styles.ghostIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<mask id={eyeMaskId}>
|
||||||
|
<rect x="0" y="0" width="24" height="24" fill="#fff" />
|
||||||
|
<circle cx="9.5" cy="10.5" r="1.3" />
|
||||||
|
<circle cx="14.5" cy="10.5" r="1.3" />
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d="M12 2C7.58 2 4 5.58 4 10v7l2.5 2.2L9 17l3 2.2 3-2.2 2.5 2.2L20 17v-7c0-4.42-3.58-8-8-8Z"
|
||||||
|
mask={`url(#${eyeMaskId})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The brand's first word fades toward transparent — a literal "ghost" for a
|
||||||
|
// Kitchen whose name starts with one. When it actually is "Ghost", its "o"
|
||||||
|
// is swapped for a small ghost mark instead (a differently-named deployment
|
||||||
|
// just gets the fade).
|
||||||
|
export const Logo = ({ name }: LogoProps) => {
|
||||||
|
const [first, ...rest] = name.split(' ')
|
||||||
|
const restText = rest.join(' ')
|
||||||
|
|
||||||
|
if (!restText) return <span className={styles.logo}>{name}</span>
|
||||||
|
|
||||||
|
const oIndex = first.toLowerCase().indexOf('o')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.logo}>
|
||||||
|
<span className={styles.faded}>
|
||||||
|
{oIndex === -1 ? (
|
||||||
|
first
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{first.slice(0, oIndex)}
|
||||||
|
<GhostIcon />
|
||||||
|
{first.slice(oIndex + 1)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>{' '}
|
||||||
|
{restText}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0 0 $space-3;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import styles from './NotFound.module.scss'
|
||||||
|
|
||||||
|
export const NotFound = () => (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<p className={styles.title}>Page not found</p>
|
||||||
|
<p className={styles.text}>
|
||||||
|
The page you're looking for doesn't exist or may have moved.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className={styles.cta}>
|
||||||
|
Browse the menu
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
@@ -55,12 +56,67 @@ export const menuItemSteps = pgTable('menu_item_steps', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => menuItems.id),
|
.references(() => menuItems.id),
|
||||||
sequence: integer().notNull(),
|
sequence: integer().notNull(),
|
||||||
|
// Customer-facing label, e.g. "Flattening dough" — distinct from the
|
||||||
|
// Machine Tag because several Steps in a Recipe can share one Tag (and
|
||||||
|
// so the same physical Machine) while doing different work on it.
|
||||||
|
name: text().notNull(),
|
||||||
machineTagId: integer('machine_tag_id')
|
machineTagId: integer('machine_tag_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => machineTags.id),
|
.references(() => machineTags.id),
|
||||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A customization a customer can toggle onto a Menu Item at order time,
|
||||||
|
// e.g. "Extra cheese" (+150 cents) or "No onions" (no price change).
|
||||||
|
export const menuItemOptions = pgTable('menu_item_options', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
menuItemId: integer('menu_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => menuItems.id),
|
||||||
|
name: text().notNull(),
|
||||||
|
priceDeltaCents: integer('price_delta_cents').notNull().default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
// A shared catalog of allergens (e.g. "Gluten", "Dairy") that Menu Items
|
||||||
|
// draw from — unlike menuItemOptions, this list is reused across items
|
||||||
|
// rather than owned by any one of them.
|
||||||
|
export const allergens = pgTable(
|
||||||
|
'allergens',
|
||||||
|
{
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
name: text().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('allergens_name_idx').on(table.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Which Allergens a Menu Item's recipe contains, as ordered by an operator.
|
||||||
|
export const menuItemAllergens = pgTable(
|
||||||
|
'menu_item_allergens',
|
||||||
|
{
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
menuItemId: integer('menu_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => menuItems.id),
|
||||||
|
allergenId: integer('allergen_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => allergens.id),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex('menu_item_allergens_item_allergen_idx').on(
|
||||||
|
table.menuItemId,
|
||||||
|
table.allergenId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
// A locker the customer collects a ready Order Item from, identified to them
|
||||||
|
// by the number on its door.
|
||||||
|
export const pickupBoxes = pgTable('pickup_boxes', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
number: integer().notNull().unique(),
|
||||||
|
isOpen: boolean('is_open').notNull().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
|
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
|
||||||
export type OrderStatus = (typeof orderStatusValues)[number]
|
export type OrderStatus = (typeof orderStatusValues)[number]
|
||||||
|
|
||||||
@@ -85,9 +141,14 @@ export const orderItemStatusValues = [
|
|||||||
'ready',
|
'ready',
|
||||||
'picked_up',
|
'picked_up',
|
||||||
'cancelled',
|
'cancelled',
|
||||||
|
// Left uncollected in its Pickup Box past the pickup window; not refunded.
|
||||||
|
'discarded',
|
||||||
] as const
|
] as const
|
||||||
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
|
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
|
||||||
|
|
||||||
|
export const orderItemCancelledByValues = ['customer', 'kitchen'] as const
|
||||||
|
export type OrderItemCancelledBy = (typeof orderItemCancelledByValues)[number]
|
||||||
|
|
||||||
export const orderItems = pgTable('order_items', {
|
export const orderItems = pgTable('order_items', {
|
||||||
id: serial().primaryKey(),
|
id: serial().primaryKey(),
|
||||||
orderId: integer('order_id')
|
orderId: integer('order_id')
|
||||||
@@ -99,9 +160,45 @@ export const orderItems = pgTable('order_items', {
|
|||||||
// Captured at order time so later menu price changes don't rewrite history.
|
// Captured at order time so later menu price changes don't rewrite history.
|
||||||
priceCents: integer('price_cents').notNull(),
|
priceCents: integer('price_cents').notNull(),
|
||||||
status: text().$type<OrderItemStatus>().notNull().default('placed'),
|
status: text().$type<OrderItemStatus>().notNull().default('placed'),
|
||||||
|
// Only set once status is 'cancelled'.
|
||||||
|
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
|
||||||
|
// Set once the item is ready and a Pickup Box is free; kept after pickup.
|
||||||
|
pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id),
|
||||||
|
// When the item went into its box, which starts the pickup window.
|
||||||
|
placedInBoxAt: timestamp('placed_in_box_at'),
|
||||||
|
pickedUpAt: timestamp('picked_up_at'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A frozen copy of a Menu Item's Recipe as it existed when a given Order
|
||||||
|
// Item was placed. A later edit to the live Recipe (menuItemSteps) never
|
||||||
|
// reshapes an Order Item already in flight — see
|
||||||
|
// docs/adr/0005-recipe-snapshot-per-order-item.md.
|
||||||
|
export const orderItemSteps = pgTable('order_item_steps', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
orderItemId: integer('order_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => orderItems.id),
|
||||||
|
sequence: integer().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
machineTagId: integer('machine_tag_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => machineTags.id),
|
||||||
|
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// A frozen copy of the Menu Item Options selected for a given Order Item —
|
||||||
|
// same reasoning as orderItemSteps: a later edit to the live option catalog
|
||||||
|
// (menuItemOptions) never rewrites an Order already placed.
|
||||||
|
export const orderItemOptions = pgTable('order_item_options', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
orderItemId: integer('order_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => orderItems.id),
|
||||||
|
name: text().notNull(),
|
||||||
|
priceDeltaCents: integer('price_delta_cents').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export const preparationStepStatusValues = [
|
export const preparationStepStatusValues = [
|
||||||
'queued',
|
'queued',
|
||||||
'in_progress',
|
'in_progress',
|
||||||
@@ -119,8 +216,19 @@ export const preparationStepInstances = pgTable('preparation_step_instances', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => machineTags.id),
|
.references(() => machineTags.id),
|
||||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||||
|
// Extra time a Machine reported on top of the estimate after claiming it.
|
||||||
|
delaySeconds: integer('delay_seconds').notNull().default(0),
|
||||||
status: text().$type<PreparationStepStatus>().notNull().default('queued'),
|
status: text().$type<PreparationStepStatus>().notNull().default('queued'),
|
||||||
machineId: integer('machine_id').references(() => machines.id),
|
machineId: integer('machine_id').references(() => machines.id),
|
||||||
startedAt: timestamp('started_at'),
|
startedAt: timestamp('started_at'),
|
||||||
doneAt: timestamp('done_at'),
|
doneAt: timestamp('done_at'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A message submitted through the Help page's contact form.
|
||||||
|
export const contactMessages = pgTable('contact_messages', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
name: text().notNull(),
|
||||||
|
email: text().notNull(),
|
||||||
|
message: text().notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
|
import type * as schema from '#/db/schema.ts'
|
||||||
|
import type { auth as Auth } from '#/lib/auth.ts'
|
||||||
|
|
||||||
|
// Drizzle types a raw `.execute()`'s rows as always present, even though
|
||||||
|
// zero rows is a real possibility at runtime (without
|
||||||
|
// noUncheckedIndexedAccess) — mirrors the firstRow helper in
|
||||||
|
// domain/ordering.ts.
|
||||||
|
const firstRow = <T>(rows: Array<T>): T | undefined => rows[0]
|
||||||
|
|
||||||
|
// The `role` field is `input: false` in the Better Auth config (see
|
||||||
|
// src/lib/auth.ts) so it can't be set through sign-up itself — an admin
|
||||||
|
// account is always a normal sign-up followed by a direct DB update here,
|
||||||
|
// never something the sign-up API can be asked to do.
|
||||||
|
export const seedAdmin = async (
|
||||||
|
db: NodePgDatabase<typeof schema>,
|
||||||
|
auth: typeof Auth,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
name = 'Admin',
|
||||||
|
) => {
|
||||||
|
let userId: string
|
||||||
|
try {
|
||||||
|
const signUp = await auth.api.signUpEmail({ body: { email, password, name } })
|
||||||
|
userId = signUp.user.id
|
||||||
|
} catch {
|
||||||
|
// Already signed up (e.g. this script has run before) — just make sure
|
||||||
|
// the existing account has the admin role.
|
||||||
|
const existing = await db.execute<{ id: string }>(
|
||||||
|
sql`select id from "user" where email = ${email}`,
|
||||||
|
)
|
||||||
|
const user = firstRow(existing.rows)
|
||||||
|
if (!user) throw new Error(`Sign-up failed and no existing user found for ${email}`)
|
||||||
|
userId = user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(sql`update "user" set role = 'admin' where id = ${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const { config } = await import('dotenv')
|
||||||
|
config({ path: ['.env.local', '.env'] })
|
||||||
|
|
||||||
|
const email = process.env.ADMIN_EMAIL
|
||||||
|
const password = process.env.ADMIN_PASSWORD
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error(
|
||||||
|
'Set ADMIN_EMAIL and ADMIN_PASSWORD (in .env.local or the environment) before running this script.',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imported dynamically, after dotenv config runs, since both #/db/index.ts
|
||||||
|
// and #/lib/auth.ts read DATABASE_URL from process.env at module load time.
|
||||||
|
const { drizzle } = await import('drizzle-orm/node-postgres')
|
||||||
|
const schemaModule = await import('#/db/schema.ts')
|
||||||
|
const { auth } = await import('#/lib/auth.ts')
|
||||||
|
const db = drizzle(process.env.DATABASE_URL!, { schema: schemaModule })
|
||||||
|
|
||||||
|
await seedAdmin(db, auth, email, password, process.env.ADMIN_NAME)
|
||||||
|
console.log(`Created admin account for ${email}.`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
+109
-4
@@ -11,6 +11,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
.values({ name: 'Ghost Kitchen' })
|
.values({ name: 'Ghost Kitchen' })
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
const [prepTag] = await db
|
||||||
|
.insert(schema.machineTags)
|
||||||
|
.values({ name: 'prep' })
|
||||||
|
.returning()
|
||||||
const [ovenTag] = await db
|
const [ovenTag] = await db
|
||||||
.insert(schema.machineTags)
|
.insert(schema.machineTags)
|
||||||
.values({ name: 'oven' })
|
.values({ name: 'oven' })
|
||||||
@@ -24,6 +28,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
.values({ name: 'assembly' })
|
.values({ name: 'assembly' })
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
const [prep1] = await db
|
||||||
|
.insert(schema.machines)
|
||||||
|
.values({ name: 'Prep Station 1', machineTagId: prepTag.id })
|
||||||
|
.returning()
|
||||||
const [oven1] = await db
|
const [oven1] = await db
|
||||||
.insert(schema.machines)
|
.insert(schema.machines)
|
||||||
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
|
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
|
||||||
@@ -37,6 +45,11 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
.values({ name: 'Assembly Station 1', machineTagId: assemblyTag.id })
|
.values({ name: 'Assembly Station 1', machineTagId: assemblyTag.id })
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
const pickupBoxes = await db
|
||||||
|
.insert(schema.pickupBoxes)
|
||||||
|
.values([1, 2, 3, 4].map((number) => ({ number })))
|
||||||
|
.returning()
|
||||||
|
|
||||||
const [pizza] = await db
|
const [pizza] = await db
|
||||||
.insert(schema.menuItems)
|
.insert(schema.menuItems)
|
||||||
.values({
|
.values({
|
||||||
@@ -47,12 +60,59 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
await db.insert(schema.menuItemSteps).values({
|
await db.insert(schema.menuItemSteps).values([
|
||||||
|
{
|
||||||
menuItemId: pizza.id,
|
menuItemId: pizza.id,
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
|
name: 'Flattening dough',
|
||||||
|
machineTagId: prepTag.id,
|
||||||
|
estimatedDurationSeconds: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuItemId: pizza.id,
|
||||||
|
sequence: 2,
|
||||||
|
name: 'Spreading sauce',
|
||||||
|
machineTagId: prepTag.id,
|
||||||
|
estimatedDurationSeconds: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuItemId: pizza.id,
|
||||||
|
sequence: 3,
|
||||||
|
name: 'Adding toppings',
|
||||||
|
machineTagId: prepTag.id,
|
||||||
|
estimatedDurationSeconds: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuItemId: pizza.id,
|
||||||
|
sequence: 4,
|
||||||
|
name: 'Baking in the oven',
|
||||||
machineTagId: ovenTag.id,
|
machineTagId: ovenTag.id,
|
||||||
estimatedDurationSeconds: 600,
|
estimatedDurationSeconds: 600,
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
menuItemId: pizza.id,
|
||||||
|
sequence: 5,
|
||||||
|
name: 'Slicing',
|
||||||
|
machineTagId: assemblyTag.id,
|
||||||
|
estimatedDurationSeconds: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuItemId: pizza.id,
|
||||||
|
sequence: 6,
|
||||||
|
name: 'Packaging',
|
||||||
|
machineTagId: assemblyTag.id,
|
||||||
|
estimatedDurationSeconds: 20,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const pizzaOptions = await db
|
||||||
|
.insert(schema.menuItemOptions)
|
||||||
|
.values([
|
||||||
|
{ menuItemId: pizza.id, name: 'Extra cheese', priceDeltaCents: 150 },
|
||||||
|
{ menuItemId: pizza.id, name: 'No onions', priceDeltaCents: 0 },
|
||||||
|
{ menuItemId: pizza.id, name: 'Extra sauce', priceDeltaCents: 0 },
|
||||||
|
])
|
||||||
|
.returning()
|
||||||
|
|
||||||
const [burger] = await db
|
const [burger] = await db
|
||||||
.insert(schema.menuItems)
|
.insert(schema.menuItems)
|
||||||
@@ -68,26 +128,71 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
{
|
{
|
||||||
menuItemId: burger.id,
|
menuItemId: burger.id,
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
|
name: 'Grilling patty',
|
||||||
machineTagId: grillTag.id,
|
machineTagId: grillTag.id,
|
||||||
estimatedDurationSeconds: 240,
|
estimatedDurationSeconds: 240,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
menuItemId: burger.id,
|
menuItemId: burger.id,
|
||||||
sequence: 2,
|
sequence: 2,
|
||||||
|
name: 'Assembling burger',
|
||||||
machineTagId: assemblyTag.id,
|
machineTagId: assemblyTag.id,
|
||||||
estimatedDurationSeconds: 90,
|
estimatedDurationSeconds: 90,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const burgerOptions = await db
|
||||||
|
.insert(schema.menuItemOptions)
|
||||||
|
.values([
|
||||||
|
{ menuItemId: burger.id, name: 'Extra cheese', priceDeltaCents: 100 },
|
||||||
|
{ menuItemId: burger.id, name: 'No pickles', priceDeltaCents: 0 },
|
||||||
|
{ menuItemId: burger.id, name: 'Extra patty', priceDeltaCents: 250 },
|
||||||
|
])
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
const allergens = await db
|
||||||
|
.insert(schema.allergens)
|
||||||
|
.values([
|
||||||
|
{ name: 'Gluten' },
|
||||||
|
{ name: 'Dairy' },
|
||||||
|
{ name: 'Eggs' },
|
||||||
|
{ name: 'Soy' },
|
||||||
|
{ name: 'Peanuts' },
|
||||||
|
{ name: 'Tree nuts' },
|
||||||
|
{ name: 'Sesame' },
|
||||||
|
])
|
||||||
|
.returning()
|
||||||
|
const allergenByName = Object.fromEntries(
|
||||||
|
allergens.map((allergen) => [allergen.name, allergen]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.insert(schema.menuItemAllergens).values([
|
||||||
|
{ menuItemId: pizza.id, allergenId: allergenByName['Gluten'].id },
|
||||||
|
{ menuItemId: pizza.id, allergenId: allergenByName['Dairy'].id },
|
||||||
|
{ menuItemId: burger.id, allergenId: allergenByName['Gluten'].id },
|
||||||
|
{ menuItemId: burger.id, allergenId: allergenByName['Sesame'].id },
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand,
|
brand,
|
||||||
machineTags: { oven: ovenTag, grill: grillTag, assembly: assemblyTag },
|
machineTags: {
|
||||||
machines: { oven1, grill1, assembly1 },
|
prep: prepTag,
|
||||||
|
oven: ovenTag,
|
||||||
|
grill: grillTag,
|
||||||
|
assembly: assemblyTag,
|
||||||
|
},
|
||||||
|
machines: { prep1, oven1, grill1, assembly1 },
|
||||||
|
pickupBoxes,
|
||||||
menuItems: { pizza, burger },
|
menuItems: { pizza, burger },
|
||||||
|
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
|
||||||
|
allergens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const { config } = await import('dotenv')
|
||||||
|
config({ path: ['.env.local', '.env'] })
|
||||||
|
|
||||||
const { drizzle } = await import('drizzle-orm/node-postgres')
|
const { drizzle } = await import('drizzle-orm/node-postgres')
|
||||||
const db = drizzle(process.env.DATABASE_URL!, { schema })
|
const db = drizzle(process.env.DATABASE_URL!, { schema })
|
||||||
await seedGhostKitchen(db)
|
await seedGhostKitchen(db)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { and, eq, sql } from 'drizzle-orm'
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
|
import * as schema from '#/db/schema.ts'
|
||||||
|
|
||||||
|
type Db = NodePgDatabase<typeof schema>
|
||||||
|
|
||||||
|
export type MachineTagOption = { id: number; name: string }
|
||||||
|
|
||||||
|
export const getMachineTags = async (
|
||||||
|
db: Db,
|
||||||
|
): Promise<Array<MachineTagOption>> =>
|
||||||
|
db.select({ id: schema.machineTags.id, name: schema.machineTags.name }).from(
|
||||||
|
schema.machineTags,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type MachineStatus = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
tagName: string
|
||||||
|
status: 'busy' | 'idle'
|
||||||
|
current: {
|
||||||
|
stepName: string
|
||||||
|
menuItemName: string
|
||||||
|
orderId: number
|
||||||
|
startedAt: Date | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagQueueSummary = {
|
||||||
|
tagName: string
|
||||||
|
idleMachineCount: number
|
||||||
|
busyMachineCount: number
|
||||||
|
queuedStepCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KitchenStatus = {
|
||||||
|
machines: Array<MachineStatus>
|
||||||
|
queues: Array<TagQueueSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
// A snapshot of every Machine's current work, and how many Preparation
|
||||||
|
// Steps are still waiting per Tag — the admin "Kitchen" page's whole model.
|
||||||
|
// Read-only: nothing here mutates state (see assignQueuedStepsForTag in
|
||||||
|
// ordering.ts for how a Machine actually picks up a queued Step).
|
||||||
|
export const getKitchenStatus = async (db: Db): Promise<KitchenStatus> => {
|
||||||
|
const machines = await db
|
||||||
|
.select({
|
||||||
|
id: schema.machines.id,
|
||||||
|
name: schema.machines.name,
|
||||||
|
tagId: schema.machineTags.id,
|
||||||
|
tagName: schema.machineTags.name,
|
||||||
|
})
|
||||||
|
.from(schema.machines)
|
||||||
|
.innerJoin(
|
||||||
|
schema.machineTags,
|
||||||
|
eq(schema.machines.machineTagId, schema.machineTags.id),
|
||||||
|
)
|
||||||
|
.orderBy(schema.machineTags.name, schema.machines.name)
|
||||||
|
|
||||||
|
const busyInstances = await db
|
||||||
|
.select({
|
||||||
|
machineId: schema.preparationStepInstances.machineId,
|
||||||
|
startedAt: schema.preparationStepInstances.startedAt,
|
||||||
|
stepName: schema.orderItemSteps.name,
|
||||||
|
orderId: schema.orders.id,
|
||||||
|
menuItemName: schema.menuItems.name,
|
||||||
|
})
|
||||||
|
.from(schema.preparationStepInstances)
|
||||||
|
.innerJoin(
|
||||||
|
schema.orderItems,
|
||||||
|
eq(schema.preparationStepInstances.orderItemId, schema.orderItems.id),
|
||||||
|
)
|
||||||
|
.innerJoin(schema.orders, eq(schema.orderItems.orderId, schema.orders.id))
|
||||||
|
.innerJoin(
|
||||||
|
schema.menuItems,
|
||||||
|
eq(schema.orderItems.menuItemId, schema.menuItems.id),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
schema.orderItemSteps,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
schema.orderItemSteps.orderItemId,
|
||||||
|
schema.preparationStepInstances.orderItemId,
|
||||||
|
),
|
||||||
|
eq(
|
||||||
|
schema.orderItemSteps.sequence,
|
||||||
|
schema.preparationStepInstances.sequence,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
|
||||||
|
|
||||||
|
const busyByMachineId = new Map(
|
||||||
|
busyInstances
|
||||||
|
.filter((row) => row.machineId !== null)
|
||||||
|
.map((row) => [row.machineId, row]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const machineStatuses: Array<MachineStatus> = machines.map((machine) => {
|
||||||
|
const busy = busyByMachineId.get(machine.id)
|
||||||
|
return {
|
||||||
|
id: machine.id,
|
||||||
|
name: machine.name,
|
||||||
|
tagName: machine.tagName,
|
||||||
|
status: busy ? 'busy' : 'idle',
|
||||||
|
current: busy
|
||||||
|
? {
|
||||||
|
stepName: busy.stepName,
|
||||||
|
menuItemName: busy.menuItemName,
|
||||||
|
orderId: busy.orderId,
|
||||||
|
startedAt: busy.startedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const queuedCounts = await db
|
||||||
|
.select({
|
||||||
|
machineTagId: schema.preparationStepInstances.machineTagId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(schema.preparationStepInstances)
|
||||||
|
.where(eq(schema.preparationStepInstances.status, 'queued'))
|
||||||
|
.groupBy(schema.preparationStepInstances.machineTagId)
|
||||||
|
|
||||||
|
const queuedCountByTagId = new Map(
|
||||||
|
queuedCounts.map((row) => [row.machineTagId, row.count]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagsById = new Map(
|
||||||
|
machines.map((machine) => [machine.tagId, machine.tagName]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const queues: Array<TagQueueSummary> = [...tagsById.entries()].map(
|
||||||
|
([tagId, tagName]) => {
|
||||||
|
const tagMachines = machineStatuses.filter((m) => m.tagName === tagName)
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
idleMachineCount: tagMachines.filter((m) => m.status === 'idle')
|
||||||
|
.length,
|
||||||
|
busyMachineCount: tagMachines.filter((m) => m.status === 'busy')
|
||||||
|
.length,
|
||||||
|
queuedStepCount: queuedCountByTagId.get(tagId) ?? 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return { machines: machineStatuses, queues }
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
|
import * as schema from '#/db/schema.ts'
|
||||||
|
|
||||||
|
type Db = NodePgDatabase<typeof schema>
|
||||||
|
|
||||||
|
export class MenuItemHasOrdersError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('This item cannot be deleted because it has past Orders')
|
||||||
|
this.name = 'MenuItemHasOrdersError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminMenuItem = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
priceCents: number
|
||||||
|
options: Array<{ id: number; name: string; priceDeltaCents: number }>
|
||||||
|
steps: Array<{
|
||||||
|
id: number
|
||||||
|
sequence: number
|
||||||
|
name: string
|
||||||
|
machineTagId: number
|
||||||
|
machineTagName: string
|
||||||
|
estimatedDurationSeconds: number
|
||||||
|
}>
|
||||||
|
allergens: Array<{ id: number; name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuItemInput = {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
priceCents: number
|
||||||
|
options: Array<{ name: string; priceDeltaCents: number }>
|
||||||
|
steps: Array<{
|
||||||
|
name: string
|
||||||
|
machineTagId: number
|
||||||
|
estimatedDurationSeconds: number
|
||||||
|
}>
|
||||||
|
allergenIds: Array<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AllergenOption = { id: number; name: string }
|
||||||
|
|
||||||
|
export const getAllergens = async (
|
||||||
|
db: Db,
|
||||||
|
): Promise<Array<AllergenOption>> =>
|
||||||
|
db
|
||||||
|
.select({ id: schema.allergens.id, name: schema.allergens.name })
|
||||||
|
.from(schema.allergens)
|
||||||
|
.orderBy(schema.allergens.name)
|
||||||
|
|
||||||
|
const assertHasSteps = (input: MenuItemInput) => {
|
||||||
|
if (input.steps.length === 0) {
|
||||||
|
throw new Error('A menu item needs at least one Preparation Step')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMenuItemsForAdmin = async (
|
||||||
|
db: Db,
|
||||||
|
brandId: number,
|
||||||
|
): Promise<Array<AdminMenuItem>> => {
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.menuItems)
|
||||||
|
.where(eq(schema.menuItems.brandId, brandId))
|
||||||
|
.orderBy(schema.menuItems.name)
|
||||||
|
|
||||||
|
const options = await db.select().from(schema.menuItemOptions)
|
||||||
|
const steps = await db
|
||||||
|
.select({
|
||||||
|
id: schema.menuItemSteps.id,
|
||||||
|
menuItemId: schema.menuItemSteps.menuItemId,
|
||||||
|
sequence: schema.menuItemSteps.sequence,
|
||||||
|
name: schema.menuItemSteps.name,
|
||||||
|
machineTagId: schema.menuItemSteps.machineTagId,
|
||||||
|
machineTagName: schema.machineTags.name,
|
||||||
|
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
||||||
|
})
|
||||||
|
.from(schema.menuItemSteps)
|
||||||
|
.innerJoin(
|
||||||
|
schema.machineTags,
|
||||||
|
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
||||||
|
)
|
||||||
|
.orderBy(schema.menuItemSteps.sequence)
|
||||||
|
|
||||||
|
const itemAllergens = await db
|
||||||
|
.select({
|
||||||
|
menuItemId: schema.menuItemAllergens.menuItemId,
|
||||||
|
id: schema.allergens.id,
|
||||||
|
name: schema.allergens.name,
|
||||||
|
})
|
||||||
|
.from(schema.menuItemAllergens)
|
||||||
|
.innerJoin(
|
||||||
|
schema.allergens,
|
||||||
|
eq(schema.menuItemAllergens.allergenId, schema.allergens.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
priceCents: item.priceCents,
|
||||||
|
options: options
|
||||||
|
.filter((option) => option.menuItemId === item.id)
|
||||||
|
.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
steps: steps
|
||||||
|
.filter((step) => step.menuItemId === item.id)
|
||||||
|
.map(({ menuItemId: _menuItemId, ...step }) => step),
|
||||||
|
allergens: itemAllergens
|
||||||
|
.filter((allergen) => allergen.menuItemId === item.id)
|
||||||
|
.map(({ id, name }) => ({ id, name })),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMenuItem = async (
|
||||||
|
db: Db,
|
||||||
|
brandId: number,
|
||||||
|
input: MenuItemInput,
|
||||||
|
): Promise<number> => {
|
||||||
|
assertHasSteps(input)
|
||||||
|
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [item] = await tx
|
||||||
|
.insert(schema.menuItems)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
priceCents: input.priceCents,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (input.options.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemOptions).values(
|
||||||
|
input.options.map((option) => ({
|
||||||
|
menuItemId: item.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(schema.menuItemSteps).values(
|
||||||
|
input.steps.map((step, index) => ({
|
||||||
|
menuItemId: item.id,
|
||||||
|
sequence: index + 1,
|
||||||
|
name: step.name,
|
||||||
|
machineTagId: step.machineTagId,
|
||||||
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (input.allergenIds.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemAllergens).values(
|
||||||
|
input.allergenIds.map((allergenId) => ({
|
||||||
|
menuItemId: item.id,
|
||||||
|
allergenId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options, Steps, and Allergens are replaced wholesale on every edit rather
|
||||||
|
// than diffed — simplest correct behavior for a form that resubmits its
|
||||||
|
// full list each time, and safe because placed Order Items snapshot their
|
||||||
|
// own copy (see orderItemSteps/orderItemOptions) so this never rewrites
|
||||||
|
// order history.
|
||||||
|
export const updateMenuItem = async (
|
||||||
|
db: Db,
|
||||||
|
menuItemId: number,
|
||||||
|
input: MenuItemInput,
|
||||||
|
): Promise<void> => {
|
||||||
|
assertHasSteps(input)
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(schema.menuItems)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
priceCents: input.priceCents,
|
||||||
|
})
|
||||||
|
.where(eq(schema.menuItems.id, menuItemId))
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemOptions)
|
||||||
|
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemSteps)
|
||||||
|
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemAllergens)
|
||||||
|
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
|
||||||
|
|
||||||
|
if (input.options.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemOptions).values(
|
||||||
|
input.options.map((option) => ({
|
||||||
|
menuItemId,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(schema.menuItemSteps).values(
|
||||||
|
input.steps.map((step, index) => ({
|
||||||
|
menuItemId,
|
||||||
|
sequence: index + 1,
|
||||||
|
name: step.name,
|
||||||
|
machineTagId: step.machineTagId,
|
||||||
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (input.allergenIds.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemAllergens).values(
|
||||||
|
input.allergenIds.map((allergenId) => ({ menuItemId, allergenId })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isForeignKeyViolation = (error: unknown) =>
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'cause' in error &&
|
||||||
|
typeof error.cause === 'object' &&
|
||||||
|
error.cause !== null &&
|
||||||
|
'code' in error.cause &&
|
||||||
|
error.cause.code === '23503'
|
||||||
|
|
||||||
|
export const deleteMenuItem = async (
|
||||||
|
db: Db,
|
||||||
|
menuItemId: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemOptions)
|
||||||
|
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemSteps)
|
||||||
|
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemAllergens)
|
||||||
|
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
|
||||||
|
// No ON DELETE CASCADE from orderItems.menuItemId — this throws (and
|
||||||
|
// rolls back the deletes above) if any Order ever referenced it.
|
||||||
|
await tx.delete(schema.menuItems).where(eq(schema.menuItems.id, menuItemId))
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (isForeignKeyViolation(error)) throw new MenuItemHasOrdersError()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
+811
-75
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 2.25rem;
|
||||||
|
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 {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +1,216 @@
|
|||||||
|
import { Link, useRouter, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { authClient } from '#/lib/auth-client'
|
import { authClient } from '#/lib/auth-client'
|
||||||
|
import { useBasket } from '#/lib/basket'
|
||||||
|
import { clearGuestOrderRefs } from '#/lib/guestOrders'
|
||||||
|
|
||||||
|
import styles from './header-user.module.scss'
|
||||||
|
|
||||||
|
const AccountIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="8" r="4" strokeWidth="1.5" />
|
||||||
|
<path d="M4 20c0-4 3.5-7 8-7s8 3 8 7" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SettingsIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.menuIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const OrdersIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.menuIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9 3h6l1 3H8l1-3Z" />
|
||||||
|
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
|
||||||
|
<path d="M9 11h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const AdminIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.menuIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6l7-3Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LogOutIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.menuIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<path d="M16 17l5-5-5-5" />
|
||||||
|
<path d="M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const BetterAuthHeader = () => {
|
const BetterAuthHeader = () => {
|
||||||
const { data: session, isPending } = authClient.useSession()
|
const { data: session, isPending } = authClient.useSession()
|
||||||
|
const { clear: clearBasket } = useBasket()
|
||||||
|
const router = useRouter()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [imageFailed, setImageFailed] = useState(false)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false)
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageFailed(false)
|
||||||
|
}, [session?.user?.image])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', onPointerDown)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown)
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <div className={`${styles.trigger} ${styles.pulse}`} />
|
||||||
<div className="h-8 w-8 bg-neutral-100 dark:bg-neutral-800 animate-pulse" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session?.user) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className={styles.wrapper} ref={wrapperRef}>
|
||||||
{session.user.image ? (
|
<button
|
||||||
<img src={session.user.image} alt="" className="h-8 w-8" />
|
type="button"
|
||||||
|
className={styles.trigger}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={
|
||||||
|
session?.user ? `Account menu for ${session.user.name}` : 'Account'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{session?.user ? (
|
||||||
|
session.user.image && !imageFailed ? (
|
||||||
|
<img
|
||||||
|
src={session.user.image}
|
||||||
|
alt=""
|
||||||
|
className={styles.avatarImage}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-8 w-8 bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center">
|
<span className={styles.avatarInitial}>
|
||||||
<span className="text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
|
||||||
{session.user.name.charAt(0).toUpperCase() || 'U'}
|
{session.user.name.charAt(0).toUpperCase() || 'U'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)
|
||||||
|
) : (
|
||||||
|
<AccountIcon />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={styles.menu} role="menu">
|
||||||
|
{session?.user ? (
|
||||||
|
<>
|
||||||
|
<p className={styles.menuLabel}>{session.user.name}</p>
|
||||||
|
<Link to="/orders" role="menuitem" className={styles.menuButton}>
|
||||||
|
<OrdersIcon />
|
||||||
|
Orders
|
||||||
|
</Link>
|
||||||
|
<Link to="/account" role="menuitem" className={styles.menuButton}>
|
||||||
|
<SettingsIcon />
|
||||||
|
Account
|
||||||
|
</Link>
|
||||||
|
{session.user.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuButton}
|
||||||
|
>
|
||||||
|
<AdminIcon />
|
||||||
|
Administration
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuButton}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void authClient.signOut()
|
setOpen(false)
|
||||||
|
clearGuestOrderRefs()
|
||||||
|
clearBasket()
|
||||||
|
void authClient.signOut({
|
||||||
|
fetchOptions: {
|
||||||
|
onSuccess: () => void router.invalidate(),
|
||||||
|
},
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
className="flex-1 h-9 px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
|
||||||
>
|
>
|
||||||
|
<LogOutIcon />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={styles.menuLabel}>Guest</p>
|
||||||
|
<Link to="/orders" role="menuitem" className={styles.menuButton}>
|
||||||
|
<OrdersIcon />
|
||||||
|
Orders
|
||||||
|
</Link>
|
||||||
|
<Link to="/login" role="menuitem" className={styles.loginButton}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link to="/signup" role="menuitem" className={styles.signupLink}>
|
||||||
|
New here? Sign up
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BetterAuthHeader
|
export default BetterAuthHeader
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// Errors cross the server-function boundary as a plain `Error` carrying only
|
||||||
|
// `.message` (TanStack Start's ShallowErrorPlugin drops the subclass), so
|
||||||
|
// requireAdmin's rejection is told apart by message rather than `instanceof`.
|
||||||
|
// Every /admin/* route loader wraps its data fetch in this so a signed-out
|
||||||
|
// or non-admin visitor is redirected to sign in instead of crashing.
|
||||||
|
export const withAdminGuard = async <T>(load: () => Promise<T>): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await load()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Admin access required') {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
import { createAuthClient } from 'better-auth/react'
|
import { createAuthClient } from 'better-auth/react'
|
||||||
|
import { inferAdditionalFields } from 'better-auth/client/plugins'
|
||||||
|
|
||||||
export const authClient = createAuthClient()
|
import type { auth } from '#/lib/auth.ts'
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [inferAdditionalFields<typeof auth>()],
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,10 +2,59 @@ import { betterAuth } from 'better-auth'
|
|||||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||||
import { Pool } from 'pg'
|
import { Pool } from 'pg'
|
||||||
|
|
||||||
|
import { db } from '#/db/index.ts'
|
||||||
|
import { detachCustomerOrders } from '#/domain/ordering.ts'
|
||||||
|
|
||||||
|
// Each provider is only registered once its credentials are configured, so
|
||||||
|
// local dev without OAuth apps set up still works via email/password.
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
// `input: false` keeps this out of the public sign-up/update schema so
|
||||||
|
// a client can't self-assign admin — it's only ever set by a direct DB
|
||||||
|
// update (see src/db/seed-admin.ts).
|
||||||
|
role: {
|
||||||
|
type: 'string',
|
||||||
|
defaultValue: 'customer',
|
||||||
|
input: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// No email-sending set up anywhere in this app (no verification flow,
|
||||||
|
// no password-reset email either), so a changed address is accepted
|
||||||
|
// immediately rather than left pending on a confirmation link that
|
||||||
|
// could never be sent.
|
||||||
|
changeEmail: {
|
||||||
|
enabled: true,
|
||||||
|
updateEmailWithoutVerification: true,
|
||||||
|
},
|
||||||
|
deleteUser: {
|
||||||
|
enabled: true,
|
||||||
|
beforeDelete: async (user) => {
|
||||||
|
await detachCustomerOrders(db, user.id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
socialProviders: {
|
||||||
|
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||||
|
? {
|
||||||
|
google: {
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET
|
||||||
|
? {
|
||||||
|
apple: {
|
||||||
|
clientId: process.env.APPLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.APPLE_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
plugins: [tanstackStartCookies()],
|
plugins: [tanstackStartCookies()],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ghost-kitchen-basket'
|
||||||
|
|
||||||
|
export type BasketLine = {
|
||||||
|
menuItemId: number
|
||||||
|
optionIds: Array<number>
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredLines = Partial<Record<string, BasketLine>>
|
||||||
|
|
||||||
|
// Two lines with the same Menu Item and the same set of selected Options
|
||||||
|
// are the same line (their quantities combine) — order of selection
|
||||||
|
// shouldn't matter, so the key sorts the option ids first.
|
||||||
|
const lineKey = (menuItemId: number, optionIds: Array<number>) =>
|
||||||
|
`${menuItemId}:${[...optionIds].sort((a, b) => a - b).join(',')}`
|
||||||
|
|
||||||
|
const isBasketLine = (value: unknown): value is BasketLine =>
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as BasketLine).menuItemId === 'number' &&
|
||||||
|
Array.isArray((value as BasketLine).optionIds) &&
|
||||||
|
typeof (value as BasketLine).quantity === 'number'
|
||||||
|
|
||||||
|
type BasketContextValue = {
|
||||||
|
lines: Array<BasketLine>
|
||||||
|
itemCount: number
|
||||||
|
add: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
remove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BasketContext = createContext<BasketContextValue | null>(null)
|
||||||
|
|
||||||
|
const readStoredLines = (): StoredLines => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return {}
|
||||||
|
|
||||||
|
const result: StoredLines = {}
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
// Earlier versions stored a flat `Record<menuItemId, quantity>` with
|
||||||
|
// no customization — read a leftover value like that as a plain,
|
||||||
|
// option-free line rather than dropping it.
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const menuItemId = Number(key)
|
||||||
|
if (!Number.isFinite(menuItemId) || value <= 0) continue
|
||||||
|
result[lineKey(menuItemId, [])] = {
|
||||||
|
menuItemId,
|
||||||
|
optionIds: [],
|
||||||
|
quantity: value,
|
||||||
|
}
|
||||||
|
} else if (isBasketLine(value)) {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasketProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [storedLines, setStoredLines] = useState<StoredLines>({})
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
setStoredLines(readStoredLines())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(storedLines))
|
||||||
|
} catch {
|
||||||
|
// Unavailable (private mode, quota) — basket still works for this
|
||||||
|
// page load, it just won't persist across a reload.
|
||||||
|
}
|
||||||
|
}, [storedLines])
|
||||||
|
|
||||||
|
const add = (menuItemId: number, optionIds: Array<number> = []) => {
|
||||||
|
const key = lineKey(menuItemId, optionIds)
|
||||||
|
setStoredLines((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
menuItemId,
|
||||||
|
optionIds,
|
||||||
|
quantity: (prev[key]?.quantity ?? 0) + 1,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (menuItemId: number, optionIds: Array<number> = []) => {
|
||||||
|
const key = lineKey(menuItemId, optionIds)
|
||||||
|
setStoredLines((prev) => {
|
||||||
|
const current = prev[key]
|
||||||
|
if (!current) return prev
|
||||||
|
if (current.quantity <= 1) {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: { ...current, quantity: current.quantity - 1 } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => setStoredLines({})
|
||||||
|
|
||||||
|
const lines = useMemo(
|
||||||
|
() => Object.values(storedLines).filter((line) => line !== undefined),
|
||||||
|
[storedLines],
|
||||||
|
)
|
||||||
|
const itemCount = useMemo(
|
||||||
|
() => lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ lines, itemCount, add, remove, clear }),
|
||||||
|
[lines, itemCount],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasketContext.Provider value={value}>{children}</BasketContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBasket = () => {
|
||||||
|
const context = useContext(BasketContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useBasket must be used within a BasketProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const priceFormatter = new Intl.NumberFormat(
|
||||||
|
import.meta.env.VITE_LOCALE || 'de-DE',
|
||||||
|
{
|
||||||
|
style: 'currency',
|
||||||
|
currency: import.meta.env.VITE_CURRENCY || 'EUR',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Intl puts the currency symbol wherever the locale's convention says to
|
||||||
|
// (before the amount for en-US, say) — reassemble the parts ourselves so
|
||||||
|
// the currency always reads after the amount, regardless of locale.
|
||||||
|
const formatAmount = (value: number) => {
|
||||||
|
const parts = priceFormatter.formatToParts(value)
|
||||||
|
const currency = parts.find((part) => part.type === 'currency')?.value ?? ''
|
||||||
|
const amount = parts
|
||||||
|
.filter((part) => part.type !== 'currency')
|
||||||
|
.map((part) => part.value)
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
return `${amount} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatPrice = (cents: number) => formatAmount(cents / 100)
|
||||||
|
|
||||||
|
export const formatPriceDelta = (cents: number) => {
|
||||||
|
if (cents === 0) return null
|
||||||
|
const amount = formatAmount(Math.abs(cents) / 100)
|
||||||
|
return cents > 0 ? `+${amount}` : `-${amount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 formatEtaParts = (etaSeconds: number, now = new Date()) =>
|
||||||
|
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
|
||||||
|
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
|
||||||
|
: { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
|
||||||
|
|
||||||
|
export const formatCountdown = (remainingMs: number) => {
|
||||||
|
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// A guest's name/contact, remembered locally so checkout doesn't ask again
|
||||||
|
// on their next order from this browser.
|
||||||
|
const STORAGE_KEY = 'ghost-kitchen-guest-identity'
|
||||||
|
|
||||||
|
export type GuestIdentity = { name: string; contact: string }
|
||||||
|
|
||||||
|
const isGuestIdentity = (value: unknown): value is GuestIdentity =>
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as GuestIdentity).name === 'string' &&
|
||||||
|
typeof (value as GuestIdentity).contact === 'string'
|
||||||
|
|
||||||
|
export const getGuestIdentity = (): GuestIdentity | null => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
return isGuestIdentity(parsed) ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveGuestIdentity = (identity: GuestIdentity) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identity))
|
||||||
|
} catch {
|
||||||
|
// Unavailable (private mode, quota) — checkout still works, it just
|
||||||
|
// asks again next time.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearGuestIdentity = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// Unavailable — nothing to clear.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Guests have no account to list Orders under, so their Order history is
|
||||||
|
// tracked client-side instead: each placed guest Order's id + access token
|
||||||
|
// is remembered here so `/orders` can look them back up without a sign-in.
|
||||||
|
const STORAGE_KEY = 'ghost-kitchen-guest-orders'
|
||||||
|
|
||||||
|
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
|
||||||
|
|
||||||
|
const isGuestOrderRef = (value: unknown): value is GuestOrderRef =>
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as GuestOrderRef).orderId === 'number' &&
|
||||||
|
typeof (value as GuestOrderRef).guestAccessToken === 'string'
|
||||||
|
|
||||||
|
export const getGuestOrderRefs = (): Array<GuestOrderRef> => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(isGuestOrderRef) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addGuestOrderRef = (ref: GuestOrderRef) => {
|
||||||
|
try {
|
||||||
|
const existing = getGuestOrderRefs().filter(
|
||||||
|
(stored) => stored.orderId !== ref.orderId,
|
||||||
|
)
|
||||||
|
window.localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([ref, ...existing]),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Unavailable (private mode, quota) — the order still works via its
|
||||||
|
// own link, it just won't show up in the local Orders list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearGuestOrderRefs = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// Unavailable — nothing to clear.
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -6,6 +6,11 @@ import type Stripe from 'stripe'
|
|||||||
// exist here — build one explicitly to load Stripe's CJS build lazily.
|
// exist here — build one explicitly to load Stripe's CJS build lazily.
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
// Stripe wants the ISO 4217 code lowercased. Shares VITE_CURRENCY with the
|
||||||
|
// display-side formatter in lib/format.ts so the charged and displayed
|
||||||
|
// currency can't drift apart.
|
||||||
|
const currency = (import.meta.env.VITE_CURRENCY || 'EUR').toLowerCase()
|
||||||
|
|
||||||
export type Charge = {
|
export type Charge = {
|
||||||
chargeId: string
|
chargeId: string
|
||||||
amountCents: number
|
amountCents: number
|
||||||
@@ -64,7 +69,7 @@ export const createStripePaymentProvider = (
|
|||||||
charge: async (amountCents) => {
|
charge: async (amountCents) => {
|
||||||
const intent = await stripe.paymentIntents.create({
|
const intent = await stripe.paymentIntents.create({
|
||||||
amount: amountCents,
|
amount: amountCents,
|
||||||
currency: 'usd',
|
currency,
|
||||||
confirm: true,
|
confirm: true,
|
||||||
payment_method: 'pm_card_visa',
|
payment_method: 'pm_card_visa',
|
||||||
automatic_payment_methods: {
|
automatic_payment_methods: {
|
||||||
|
|||||||
+352
-3
@@ -10,6 +10,22 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as AccountRouteImport } from './routes/account'
|
||||||
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
|
import { Route as BasketRouteImport } from './routes/basket'
|
||||||
|
import { Route as HelpRouteImport } from './routes/help'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
|
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||||
|
import { Route as AdminKitchenRouteImport } from './routes/admin/kitchen'
|
||||||
|
import { Route as ItemsMenuItemIdRouteImport } from './routes/items/$menuItemId'
|
||||||
|
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
|
||||||
|
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
||||||
|
import { Route as AdminMenuIndexRouteImport } from './routes/admin/menu/index'
|
||||||
|
import { Route as AdminMenuMenuItemIdRouteImport } from './routes/admin/menu/$menuItemId'
|
||||||
|
import { Route as AdminMenuNewRouteImport } from './routes/admin/menu/new'
|
||||||
|
import { Route as AdminOrdersIndexRouteImport } from './routes/admin/orders/index'
|
||||||
|
import { Route as AdminOrdersOrderIdRouteImport } from './routes/admin/orders/$orderId'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -17,6 +33,86 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AccountRoute = AccountRouteImport.update({
|
||||||
|
id: '/account',
|
||||||
|
path: '/account',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AdminRoute = AdminRouteImport.update({
|
||||||
|
id: '/admin',
|
||||||
|
path: '/admin',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const BasketRoute = BasketRouteImport.update({
|
||||||
|
id: '/basket',
|
||||||
|
path: '/basket',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const HelpRoute = HelpRouteImport.update({
|
||||||
|
id: '/help',
|
||||||
|
path: '/help',
|
||||||
|
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 AdminIndexRoute = AdminIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminKitchenRoute = AdminKitchenRouteImport.update({
|
||||||
|
id: '/kitchen',
|
||||||
|
path: '/kitchen',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
|
||||||
|
id: '/items/$menuItemId',
|
||||||
|
path: '/items/$menuItemId',
|
||||||
|
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',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AdminMenuIndexRoute = AdminMenuIndexRouteImport.update({
|
||||||
|
id: '/menu/',
|
||||||
|
path: '/menu/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminMenuMenuItemIdRoute = AdminMenuMenuItemIdRouteImport.update({
|
||||||
|
id: '/menu/$menuItemId',
|
||||||
|
path: '/menu/$menuItemId',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminMenuNewRoute = AdminMenuNewRouteImport.update({
|
||||||
|
id: '/menu/new',
|
||||||
|
path: '/menu/new',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminOrdersIndexRoute = AdminOrdersIndexRouteImport.update({
|
||||||
|
id: '/orders/',
|
||||||
|
path: '/orders/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminOrdersOrderIdRoute = AdminOrdersOrderIdRouteImport.update({
|
||||||
|
id: '/orders/$orderId',
|
||||||
|
path: '/orders/$orderId',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||||
id: '/api/auth/$',
|
id: '/api/auth/$',
|
||||||
path: '/api/auth/$',
|
path: '/api/auth/$',
|
||||||
@@ -25,27 +121,137 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/account': typeof AccountRoute
|
||||||
|
'/admin': typeof AdminRouteWithChildren
|
||||||
|
'/basket': typeof BasketRoute
|
||||||
|
'/help': typeof HelpRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/admin/': typeof AdminIndexRoute
|
||||||
|
'/orders/': typeof OrdersIndexRoute
|
||||||
|
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||||
|
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||||
|
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/admin/menu/': typeof AdminMenuIndexRoute
|
||||||
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/account': typeof AccountRoute
|
||||||
|
'/basket': typeof BasketRoute
|
||||||
|
'/help': typeof HelpRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/admin': typeof AdminIndexRoute
|
||||||
|
'/orders': typeof OrdersIndexRoute
|
||||||
|
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||||
|
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||||
|
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/admin/menu': typeof AdminMenuIndexRoute
|
||||||
|
'/admin/orders': typeof AdminOrdersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/account': typeof AccountRoute
|
||||||
|
'/admin': typeof AdminRouteWithChildren
|
||||||
|
'/basket': typeof BasketRoute
|
||||||
|
'/help': typeof HelpRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/admin/': typeof AdminIndexRoute
|
||||||
|
'/orders/': typeof OrdersIndexRoute
|
||||||
|
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||||
|
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||||
|
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/admin/menu/': typeof AdminMenuIndexRoute
|
||||||
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/api/auth/$'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/account'
|
||||||
|
| '/admin'
|
||||||
|
| '/basket'
|
||||||
|
| '/help'
|
||||||
|
| '/login'
|
||||||
|
| '/signup'
|
||||||
|
| '/admin/kitchen'
|
||||||
|
| '/items/$menuItemId'
|
||||||
|
| '/orders/$orderId'
|
||||||
|
| '/admin/'
|
||||||
|
| '/orders/'
|
||||||
|
| '/admin/menu/$menuItemId'
|
||||||
|
| '/admin/menu/new'
|
||||||
|
| '/admin/orders/$orderId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/admin/menu/'
|
||||||
|
| '/admin/orders/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/api/auth/$'
|
to:
|
||||||
id: '__root__' | '/' | '/api/auth/$'
|
| '/'
|
||||||
|
| '/account'
|
||||||
|
| '/basket'
|
||||||
|
| '/help'
|
||||||
|
| '/login'
|
||||||
|
| '/signup'
|
||||||
|
| '/admin/kitchen'
|
||||||
|
| '/items/$menuItemId'
|
||||||
|
| '/orders/$orderId'
|
||||||
|
| '/admin'
|
||||||
|
| '/orders'
|
||||||
|
| '/admin/menu/$menuItemId'
|
||||||
|
| '/admin/menu/new'
|
||||||
|
| '/admin/orders/$orderId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/admin/menu'
|
||||||
|
| '/admin/orders'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/account'
|
||||||
|
| '/admin'
|
||||||
|
| '/basket'
|
||||||
|
| '/help'
|
||||||
|
| '/login'
|
||||||
|
| '/signup'
|
||||||
|
| '/admin/kitchen'
|
||||||
|
| '/items/$menuItemId'
|
||||||
|
| '/orders/$orderId'
|
||||||
|
| '/admin/'
|
||||||
|
| '/orders/'
|
||||||
|
| '/admin/menu/$menuItemId'
|
||||||
|
| '/admin/menu/new'
|
||||||
|
| '/admin/orders/$orderId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/admin/menu/'
|
||||||
|
| '/admin/orders/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AccountRoute: typeof AccountRoute
|
||||||
|
AdminRoute: typeof AdminRouteWithChildren
|
||||||
|
BasketRoute: typeof BasketRoute
|
||||||
|
HelpRoute: typeof HelpRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
SignupRoute: typeof SignupRoute
|
||||||
|
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
|
||||||
|
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
||||||
|
OrdersIndexRoute: typeof OrdersIndexRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +264,118 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/account': {
|
||||||
|
id: '/account'
|
||||||
|
path: '/account'
|
||||||
|
fullPath: '/account'
|
||||||
|
preLoaderRoute: typeof AccountRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/admin': {
|
||||||
|
id: '/admin'
|
||||||
|
path: '/admin'
|
||||||
|
fullPath: '/admin'
|
||||||
|
preLoaderRoute: typeof AdminRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/basket': {
|
||||||
|
id: '/basket'
|
||||||
|
path: '/basket'
|
||||||
|
fullPath: '/basket'
|
||||||
|
preLoaderRoute: typeof BasketRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/help': {
|
||||||
|
id: '/help'
|
||||||
|
path: '/help'
|
||||||
|
fullPath: '/help'
|
||||||
|
preLoaderRoute: typeof HelpRouteImport
|
||||||
|
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
|
||||||
|
}
|
||||||
|
'/admin/': {
|
||||||
|
id: '/admin/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/admin/'
|
||||||
|
preLoaderRoute: typeof AdminIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/kitchen': {
|
||||||
|
id: '/admin/kitchen'
|
||||||
|
path: '/kitchen'
|
||||||
|
fullPath: '/admin/kitchen'
|
||||||
|
preLoaderRoute: typeof AdminKitchenRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/items/$menuItemId': {
|
||||||
|
id: '/items/$menuItemId'
|
||||||
|
path: '/items/$menuItemId'
|
||||||
|
fullPath: '/items/$menuItemId'
|
||||||
|
preLoaderRoute: typeof ItemsMenuItemIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/orders/': {
|
||||||
|
id: '/orders/'
|
||||||
|
path: '/orders'
|
||||||
|
fullPath: '/orders/'
|
||||||
|
preLoaderRoute: typeof OrdersIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/orders/$orderId': {
|
||||||
|
id: '/orders/$orderId'
|
||||||
|
path: '/orders/$orderId'
|
||||||
|
fullPath: '/orders/$orderId'
|
||||||
|
preLoaderRoute: typeof OrdersOrderIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/admin/menu/': {
|
||||||
|
id: '/admin/menu/'
|
||||||
|
path: '/menu'
|
||||||
|
fullPath: '/admin/menu/'
|
||||||
|
preLoaderRoute: typeof AdminMenuIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/menu/$menuItemId': {
|
||||||
|
id: '/admin/menu/$menuItemId'
|
||||||
|
path: '/menu/$menuItemId'
|
||||||
|
fullPath: '/admin/menu/$menuItemId'
|
||||||
|
preLoaderRoute: typeof AdminMenuMenuItemIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/menu/new': {
|
||||||
|
id: '/admin/menu/new'
|
||||||
|
path: '/menu/new'
|
||||||
|
fullPath: '/admin/menu/new'
|
||||||
|
preLoaderRoute: typeof AdminMenuNewRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/orders/': {
|
||||||
|
id: '/admin/orders/'
|
||||||
|
path: '/orders'
|
||||||
|
fullPath: '/admin/orders/'
|
||||||
|
preLoaderRoute: typeof AdminOrdersIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/orders/$orderId': {
|
||||||
|
id: '/admin/orders/$orderId'
|
||||||
|
path: '/orders/$orderId'
|
||||||
|
fullPath: '/admin/orders/$orderId'
|
||||||
|
preLoaderRoute: typeof AdminOrdersOrderIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/api/auth/$': {
|
'/api/auth/$': {
|
||||||
id: '/api/auth/$'
|
id: '/api/auth/$'
|
||||||
path: '/api/auth/$'
|
path: '/api/auth/$'
|
||||||
@@ -68,8 +386,39 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AdminRouteChildren {
|
||||||
|
AdminKitchenRoute: typeof AdminKitchenRoute
|
||||||
|
AdminIndexRoute: typeof AdminIndexRoute
|
||||||
|
AdminMenuMenuItemIdRoute: typeof AdminMenuMenuItemIdRoute
|
||||||
|
AdminMenuNewRoute: typeof AdminMenuNewRoute
|
||||||
|
AdminOrdersOrderIdRoute: typeof AdminOrdersOrderIdRoute
|
||||||
|
AdminMenuIndexRoute: typeof AdminMenuIndexRoute
|
||||||
|
AdminOrdersIndexRoute: typeof AdminOrdersIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteChildren: AdminRouteChildren = {
|
||||||
|
AdminKitchenRoute: AdminKitchenRoute,
|
||||||
|
AdminIndexRoute: AdminIndexRoute,
|
||||||
|
AdminMenuMenuItemIdRoute: AdminMenuMenuItemIdRoute,
|
||||||
|
AdminMenuNewRoute: AdminMenuNewRoute,
|
||||||
|
AdminOrdersOrderIdRoute: AdminOrdersOrderIdRoute,
|
||||||
|
AdminMenuIndexRoute: AdminMenuIndexRoute,
|
||||||
|
AdminOrdersIndexRoute: AdminOrdersIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AccountRoute: AccountRoute,
|
||||||
|
AdminRoute: AdminRouteWithChildren,
|
||||||
|
BasketRoute: BasketRoute,
|
||||||
|
HelpRoute: HelpRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
|
SignupRoute: SignupRoute,
|
||||||
|
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
|
||||||
|
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
||||||
|
OrdersIndexRoute: OrdersIndexRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
export function getRouter() {
|
export const getRouter = () => {
|
||||||
const router = createTanStackRouter({
|
const router = createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultViewTransition: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
|
||||||
|
.topBar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
// 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;
|
||||||
|
background: color-mix(in srgb, $color-background 30%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, $color-border 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $color-text;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
+48
-20
@@ -1,33 +1,55 @@
|
|||||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
import {
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
HeadContent,
|
||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
Link,
|
||||||
|
Scripts,
|
||||||
|
createRootRoute,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
|
||||||
import appCss from '../styles.css?url'
|
import { BasketLink } from '#/components/basket/BasketLink.tsx'
|
||||||
|
import { Footer } from '#/components/ui/Footer.tsx'
|
||||||
|
import { Logo } from '#/components/ui/Logo.tsx'
|
||||||
|
import { NotFound } from '#/components/ui/NotFound.tsx'
|
||||||
|
import BetterAuthHeader from '#/integrations/better-auth/header-user.tsx'
|
||||||
|
import { BasketProvider } from '#/lib/basket.tsx'
|
||||||
|
import { getBrand } from '#/server/brand.ts'
|
||||||
|
|
||||||
const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
import '../styles/globals.scss'
|
||||||
|
import styles from './__root.module.scss'
|
||||||
|
|
||||||
|
const RootDocument = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { brand } = Route.useLoaderData()
|
||||||
|
|
||||||
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{children}
|
<BasketProvider>
|
||||||
<TanStackDevtools
|
<div
|
||||||
config={{
|
className={styles.topBar}
|
||||||
position: 'bottom-right',
|
style={{ viewTransitionName: 'site-header' }}
|
||||||
}}
|
>
|
||||||
plugins={[
|
<Link to="/" className={styles.brand}>
|
||||||
{
|
<Logo name={brand.name} />
|
||||||
name: 'Tanstack Router',
|
</Link>
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
<div className={styles.actions}>
|
||||||
},
|
<BasketLink />
|
||||||
]}
|
<BetterAuthHeader />
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
<Footer brandName={brand.name} />
|
||||||
|
</BasketProvider>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
|
loader: async () => ({ brand: await getBrand() }),
|
||||||
|
notFoundComponent: NotFound,
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
@@ -38,13 +60,19 @@ export const Route = createRootRoute({
|
|||||||
content: 'width=device-width, initial-scale=1',
|
content: 'width=device-width, initial-scale=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'TanStack Start Starter',
|
title: 'Ghost Kitchen',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
|
{
|
||||||
|
rel: 'preconnect',
|
||||||
|
href: 'https://fonts.gstatic.com',
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rel: 'stylesheet',
|
rel: 'stylesheet',
|
||||||
href: appCss,
|
href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
padding: $space-4;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
background: $color-background;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $color-danger;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: $color-success;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dangerZone {
|
||||||
|
border-color: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dangerText {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dangerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border: 1px solid $color-danger;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-danger;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $color-danger;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
|
||||||
|
import { authClient } from '#/lib/auth-client.ts'
|
||||||
|
import { getCurrentUser } from '#/server/auth.ts'
|
||||||
|
|
||||||
|
import styles from './account.module.scss'
|
||||||
|
|
||||||
|
const AccountPage = () => {
|
||||||
|
const user = Route.useLoaderData()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [name, setName] = useState(user.name)
|
||||||
|
const [email, setEmail] = useState(user.email)
|
||||||
|
const [savedName, setSavedName] = useState(user.name)
|
||||||
|
const [savedEmail, setSavedEmail] = useState(user.email)
|
||||||
|
const [profileSubmitting, setProfileSubmitting] = useState(false)
|
||||||
|
const [profileError, setProfileError] = useState<string | null>(null)
|
||||||
|
const [profileSaved, setProfileSaved] = useState(false)
|
||||||
|
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [passwordSubmitting, setPasswordSubmitting] = useState(false)
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||||||
|
const [passwordSaved, setPasswordSaved] = useState(false)
|
||||||
|
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
const [deletePassword, setDeletePassword] = useState('')
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const submitProfile = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setProfileSubmitting(true)
|
||||||
|
setProfileError(null)
|
||||||
|
setProfileSaved(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (name !== savedName) {
|
||||||
|
const { error } = await authClient.updateUser({ name })
|
||||||
|
if (error) throw new Error(error.message ?? 'Could not update your name.')
|
||||||
|
setSavedName(name)
|
||||||
|
}
|
||||||
|
if (email !== savedEmail) {
|
||||||
|
const { error } = await authClient.changeEmail({ newEmail: email })
|
||||||
|
if (error) throw new Error(error.message ?? 'Could not update your email.')
|
||||||
|
setSavedEmail(email)
|
||||||
|
}
|
||||||
|
setProfileSaved(true)
|
||||||
|
} catch (err) {
|
||||||
|
setProfileError(
|
||||||
|
err instanceof Error ? err.message : 'Something went wrong.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setProfileSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPassword = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setPasswordSubmitting(true)
|
||||||
|
setPasswordError(null)
|
||||||
|
setPasswordSaved(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.changePassword({
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
})
|
||||||
|
if (error) throw new Error(error.message ?? 'Could not change your password.')
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setPasswordSaved(true)
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordError(
|
||||||
|
err instanceof Error ? err.message : 'Something went wrong.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setPasswordSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAccount = async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
setDeleteError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.deleteUser(
|
||||||
|
deletePassword ? { password: deletePassword } : {},
|
||||||
|
)
|
||||||
|
if (error) throw new Error(error.message ?? 'Could not delete your account.')
|
||||||
|
await navigate({ to: '/' })
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(
|
||||||
|
err instanceof Error ? err.message : 'Something went wrong.',
|
||||||
|
)
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.page}>
|
||||||
|
<h1 className={styles.heading}>Account</h1>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionHeading}>Profile</h2>
|
||||||
|
<form className={styles.form} onSubmit={submitProfile}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.submitButton}
|
||||||
|
disabled={profileSubmitting}
|
||||||
|
>
|
||||||
|
{profileSubmitting ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
{profileError && <p className={styles.error}>{profileError}</p>}
|
||||||
|
{profileSaved && <p className={styles.success}>Saved.</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionHeading}>Password</h2>
|
||||||
|
<form className={styles.form} onSubmit={submitPassword}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Current password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
New password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.submitButton}
|
||||||
|
disabled={passwordSubmitting}
|
||||||
|
>
|
||||||
|
{passwordSubmitting ? 'Changing…' : 'Change password'}
|
||||||
|
</button>
|
||||||
|
{passwordError && <p className={styles.error}>{passwordError}</p>}
|
||||||
|
{passwordSaved && <p className={styles.success}>Password changed.</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${styles.section} ${styles.dangerZone}`}>
|
||||||
|
<h2 className={styles.sectionHeading}>Delete account</h2>
|
||||||
|
<p className={styles.dangerText}>
|
||||||
|
This permanently deletes your account. Your past orders stay on
|
||||||
|
record but are no longer linked to you. This can’t be undone.
|
||||||
|
</p>
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<div className={styles.form}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Confirm your password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={deletePassword}
|
||||||
|
onChange={(event) => setDeletePassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={styles.dangerActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteButton}
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => void deleteAccount()}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Permanently delete my account'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelButton}
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmingDelete(false)
|
||||||
|
setDeletePassword('')
|
||||||
|
setDeleteError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{deleteError && <p className={styles.error}>{deleteError}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onClick={() => setConfirmingDelete(true)}
|
||||||
|
>
|
||||||
|
Delete account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/account')({
|
||||||
|
loader: async () => {
|
||||||
|
try {
|
||||||
|
return await getCurrentUser()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Not signed in') {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AccountPage,
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $space-4;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-4 $space-3;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
// align-items controls the cross axis, which flips from height to
|
||||||
|
// width once this switches to a column — flex-start (correct for
|
||||||
|
// desktop, so the sidebar and content don't stretch to match heights)
|
||||||
|
// would otherwise shrink-to-fit each child's own content width instead
|
||||||
|
// of stretching it to the viewport, letting long content overflow.
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1;
|
||||||
|
position: sticky;
|
||||||
|
top: $space-4;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
// `.layout` stacks to a column below, so this flex-basis would
|
||||||
|
// otherwise fix the sidebar's *height* at 200px instead of its width
|
||||||
|
// — reset it and stop the row of links from stretching to fill that.
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
position: static;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarTitle {
|
||||||
|
margin: 0 0 $space-2;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
padding: $space-2 $space-3;
|
||||||
|
border-radius: $radius;
|
||||||
|
color: $color-text;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive {
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { requireAdminSession } from '#/server/auth.ts'
|
||||||
|
|
||||||
|
import styles from './admin.module.scss'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: '/admin/orders', label: 'Orders' },
|
||||||
|
{ to: '/admin/menu', label: 'Menu' },
|
||||||
|
{ to: '/admin/kitchen', label: 'Kitchen' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const AdminLayout = () => (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<nav
|
||||||
|
className={styles.sidebar}
|
||||||
|
style={{ viewTransitionName: 'admin-sidebar' }}
|
||||||
|
>
|
||||||
|
<p className={styles.sidebarTitle}>Administration</p>
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={styles.navLink}
|
||||||
|
activeProps={{ className: styles.navLinkActive }}
|
||||||
|
// The sidebar and overall layout never change between /admin/*
|
||||||
|
// pages — a view transition's root crossfade has nothing useful
|
||||||
|
// to animate here and just makes the static sidebar look like
|
||||||
|
// it's moving, so navigation within the admin panel skips it.
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin')({
|
||||||
|
loader: () => withAdminGuard(() => requireAdminSession()),
|
||||||
|
component: AdminLayout,
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user