Compare commits
51
Commits
ff8fc388c0
...
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 |
@@ -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,
|
||||||
|
|||||||
@@ -1 +1,22 @@
|
|||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
|
DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
|
# 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>
|
||||||
@@ -38,8 +38,13 @@ 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
|
||||||
@@ -73,7 +78,8 @@ drizzle/ # generated SQL migrations — never hand-edit
|
|||||||
- 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
|
||||||
@@ -94,7 +100,10 @@ drizzle/ # generated SQL migrations — never hand-edit
|
|||||||
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
|
||||||
|
|||||||
+9
-2
@@ -16,14 +16,14 @@ _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 once every Order Item within it has reached a terminal state — picked up or cancelled. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.)
|
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**:
|
**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.
|
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)
|
_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 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).
|
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**:
|
**Guest Access Token**:
|
||||||
@@ -37,6 +37,9 @@ The ordered list of Preparation Steps required to prepare a Menu Item. A Menu It
|
|||||||
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.
|
||||||
|
|
||||||
@@ -46,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.
|
||||||
|
|||||||
@@ -23,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
|
||||||
|
|
||||||
@@ -56,7 +50,7 @@ etc). A `docker-compose.yml` at the repo root starts one:
|
|||||||
docker compose up -d # starts Postgres on localhost:5433
|
docker compose up -d # starts Postgres on localhost:5433
|
||||||
cp .env.example .env.local # sets DATABASE_URL to match
|
cp .env.example .env.local # sets DATABASE_URL to match
|
||||||
npm run db:migrate # applies this project's Drizzle migrations
|
npm run db:migrate # applies this project's Drizzle migrations
|
||||||
npx @better-auth/cli migrate # applies Better Auth's own tables
|
npm run auth:migrate # applies Better Auth's own tables
|
||||||
npm run db:seed # seeds a sample Brand, menu, and Machines
|
npm run db:seed # seeds a sample Brand, menu, and Machines
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -75,14 +69,47 @@ npm run db:migrate
|
|||||||
## Setting up Better Auth
|
## Setting up Better Auth
|
||||||
|
|
||||||
Generate and set the `BETTER_AUTH_SECRET` environment variable in your
|
Generate and set the `BETTER_AUTH_SECRET` environment variable in your
|
||||||
`.env.local`:
|
`.env.local`, and make sure `BETTER_AUTH_URL` matches the URL the app is
|
||||||
|
actually served from (`http://localhost:3000` in local dev):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y @better-auth/cli secret
|
npx @better-auth/cli secret
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [Better Auth documentation](https://www.better-auth.com) for more on
|
Account creation and sign-in live at `/signup` and `/login`, supporting both
|
||||||
configuring authentication.
|
email/password and "Continue with Google" / "Continue with Apple" SSO. The
|
||||||
|
SSO buttons only appear once the corresponding provider's credentials are
|
||||||
|
set in `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOOGLE_CLIENT_ID=...
|
||||||
|
GOOGLE_CLIENT_SECRET=...
|
||||||
|
APPLE_CLIENT_ID=...
|
||||||
|
APPLE_CLIENT_SECRET=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Create OAuth credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
|
||||||
|
and the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list/serviceId)
|
||||||
|
respectively, with the redirect URI `{BETTER_AUTH_URL}/api/auth/callback/google`
|
||||||
|
(swap `google` for `apple`). See the
|
||||||
|
[Better Auth documentation](https://www.better-auth.com) for more on
|
||||||
|
configuring authentication, and its
|
||||||
|
[Google](https://www.better-auth.com/docs/authentication/google) and
|
||||||
|
[Apple](https://www.better-auth.com/docs/authentication/apple) provider
|
||||||
|
guides for provider-specific setup details.
|
||||||
|
|
||||||
|
For Google, `scripts/setup-google-sso.sh` walks you through the Google Cloud
|
||||||
|
Console steps interactively and writes the resulting `GOOGLE_CLIENT_ID` /
|
||||||
|
`GOOGLE_CLIENT_SECRET` into `.env` for you:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup-google-sso.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Apple's Sign in with Apple doesn't support `localhost` redirect URIs — you
|
||||||
|
need a real HTTPS domain (a deployment or a tunnel like ngrok) registered on
|
||||||
|
the Services ID before it's testable, so there's no equivalent script for it
|
||||||
|
yet.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "menu_item_steps" ADD COLUMN "name" text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_item_steps" ADD COLUMN "name" text NOT NULL;
|
||||||
@@ -0,0 +1,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,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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,62 @@
|
|||||||
"when": 1788976393301,
|
"when": 1788976393301,
|
||||||
"tag": "0002_large_lake",
|
"tag": "0002_large_lake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789028563859,
|
||||||
|
"tag": "0003_soft_psynapse",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
+1484
-300
File diff suppressed because it is too large
Load Diff
+5
-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",
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
"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": "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 .",
|
||||||
@@ -24,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",
|
||||||
@@ -32,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",
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@
|
|||||||
color: $color-primary-contrast;
|
color: $color-primary-contrast;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -43,3 +48,18 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forgetButton {
|
||||||
|
align-self: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { authClient } from '#/lib/auth-client.ts'
|
import { authClient } from '#/lib/auth-client.ts'
|
||||||
|
import {
|
||||||
|
clearGuestIdentity,
|
||||||
|
getGuestIdentity,
|
||||||
|
saveGuestIdentity,
|
||||||
|
} from '#/lib/guestIdentity.ts'
|
||||||
|
import type { GuestIdentity } from '#/lib/guestIdentity.ts'
|
||||||
|
import { addGuestOrderRef } from '#/lib/guestOrders.ts'
|
||||||
import { placeOrder } from '#/server/orders.ts'
|
import { placeOrder } from '#/server/orders.ts'
|
||||||
|
|
||||||
import styles from './CheckoutSection.module.scss'
|
import styles from './CheckoutSection.module.scss'
|
||||||
@@ -12,7 +19,7 @@ type Customer =
|
|||||||
type PlacedOrder = { id: number; guestAccessToken: string | null }
|
type PlacedOrder = { id: number; guestAccessToken: string | null }
|
||||||
|
|
||||||
type CheckoutSectionProps = {
|
type CheckoutSectionProps = {
|
||||||
items: Array<{ menuItemId: number }>
|
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onPlaced: (order: PlacedOrder) => void
|
onPlaced: (order: PlacedOrder) => void
|
||||||
}
|
}
|
||||||
@@ -25,14 +32,36 @@ export const CheckoutSection = ({
|
|||||||
const { data: session, isPending } = authClient.useSession()
|
const { data: session, isPending } = authClient.useSession()
|
||||||
const [guestName, setGuestName] = useState('')
|
const [guestName, setGuestName] = useState('')
|
||||||
const [guestContact, setGuestContact] = useState('')
|
const [guestContact, setGuestContact] = useState('')
|
||||||
|
const [rememberedGuest, setRememberedGuest] = useState<GuestIdentity | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Read after mount, not during initial render, so the server-rendered and
|
||||||
|
// first client-rendered HTML match (no localStorage on the server) and
|
||||||
|
// React doesn't flag a hydration mismatch — same reasoning as
|
||||||
|
// BasketProvider.
|
||||||
|
useEffect(() => {
|
||||||
|
setRememberedGuest(getGuestIdentity())
|
||||||
|
}, [])
|
||||||
|
|
||||||
const submit = async (customer: Customer) => {
|
const submit = async (customer: Customer) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const order = await placeOrder({ data: { items, customer } })
|
const order = await placeOrder({ data: { items, customer } })
|
||||||
|
if (order.guestAccessToken) {
|
||||||
|
addGuestOrderRef({
|
||||||
|
orderId: order.id,
|
||||||
|
guestAccessToken: order.guestAccessToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (customer.type === 'guest') {
|
||||||
|
const identity = { name: customer.name, contact: customer.contact }
|
||||||
|
saveGuestIdentity(identity)
|
||||||
|
setRememberedGuest(identity)
|
||||||
|
}
|
||||||
onPlaced(order)
|
onPlaced(order)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -56,7 +85,35 @@ export const CheckoutSection = ({
|
|||||||
disabled={disabled || submitting}
|
disabled={disabled || submitting}
|
||||||
onClick={() => submit({ type: 'account' })}
|
onClick={() => submit({ type: 'account' })}
|
||||||
>
|
>
|
||||||
{submitting ? 'Placing order…' : `Place order as ${session.user.name}`}
|
{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>
|
</button>
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,16 +10,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $space-3;
|
|
||||||
padding: $space-3;
|
padding: $space-3;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
background: $color-surface;
|
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 {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -27,6 +53,7 @@
|
|||||||
.name {
|
.name {
|
||||||
margin: 0 0 $space-1;
|
margin: 0 0 $space-1;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
@@ -41,6 +68,12 @@
|
|||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.allergens {
|
||||||
|
margin: $space-1 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
.quantity {
|
.quantity {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -48,12 +81,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stepperButton {
|
.stepperButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|||||||
@@ -1,37 +1,43 @@
|
|||||||
|
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 { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
import type { BasketLine } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
import styles from './MenuList.module.scss'
|
import styles from './MenuList.module.scss'
|
||||||
|
|
||||||
type MenuListProps = {
|
type MenuListProps = {
|
||||||
menu: Array<MenuItemWithRecipe>
|
menu: Array<MenuItemWithRecipe>
|
||||||
quantities: Record<number, number>
|
lines: Array<BasketLine>
|
||||||
onAdd: (menuItemId: number) => void
|
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
onRemove: (menuItemId: number) => void
|
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
export const formatDuration = (seconds: number) => {
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
|
||||||
const minutes = Math.round(seconds / 60)
|
const minutes = Math.round(seconds / 60)
|
||||||
return minutes <= 1 ? '~1 min' : `~${minutes} min`
|
return minutes <= 1 ? '1 min' : `${minutes} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MenuList = ({
|
export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
|
||||||
menu,
|
|
||||||
quantities,
|
|
||||||
onAdd,
|
|
||||||
onRemove,
|
|
||||||
}: MenuListProps) => (
|
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{menu.map((item) => {
|
{menu.map((item) => {
|
||||||
const quantity = quantities[item.id] ?? 0
|
const defaultLine = lines.find(
|
||||||
|
(line) => line.menuItemId === item.id && line.optionIds.length === 0,
|
||||||
|
)
|
||||||
|
const hasOptions = item.options.length > 0
|
||||||
const totalSeconds = item.steps.reduce(
|
const totalSeconds = item.steps.reduce(
|
||||||
(sum, step) => sum + step.estimatedDurationSeconds,
|
(sum, step) => sum + step.estimatedDurationSeconds,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const info = (
|
||||||
<li key={item.id} className={styles.item}>
|
<>
|
||||||
|
<MenuItemThumbnail
|
||||||
|
name={item.name}
|
||||||
|
style={{ viewTransitionName: `menu-item-${item.id}` }}
|
||||||
|
/>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<h3 className={styles.name}>{item.name}</h3>
|
<h3 className={styles.name}>{item.name}</h3>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
@@ -40,27 +46,54 @@ export const MenuList = ({
|
|||||||
<p className={styles.meta}>
|
<p className={styles.meta}>
|
||||||
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||||
</p>
|
</p>
|
||||||
|
{item.allergens.length > 0 && (
|
||||||
|
<p className={styles.allergens}>
|
||||||
|
Contains:{' '}
|
||||||
|
{item.allergens.map((allergen) => allergen.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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}>
|
<div className={styles.quantity}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
className={styles.stepperButton}
|
||||||
onClick={() => onRemove(item.id)}
|
onClick={() => onRemove(item.id)}
|
||||||
disabled={quantity === 0}
|
disabled={(defaultLine?.quantity ?? 0) === 0}
|
||||||
aria-label={`Remove one ${item.name}`}
|
aria-label={`Remove one ${item.name}`}
|
||||||
>
|
>
|
||||||
−
|
<span aria-hidden="true">−</span>
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.count}>{quantity}</span>
|
<AnimatedNumber
|
||||||
|
value={defaultLine?.quantity ?? 0}
|
||||||
|
className={styles.count}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
className={styles.stepperButton}
|
||||||
onClick={() => onAdd(item.id)}
|
onClick={() => onAdd(item.id)}
|
||||||
aria-label={`Add one ${item.name}`}
|
aria-label={`Add one ${item.name}`}
|
||||||
>
|
>
|
||||||
+
|
<span aria-hidden="true">+</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
@use '../../styles/variables' as *;
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: $color-text-muted;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $space-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lines {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $space-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-weight: 600;
|
|
||||||
padding-top: $space-2;
|
|
||||||
border-top: 1px solid $color-border;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
|
||||||
|
|
||||||
import styles from './OrderSummary.module.scss'
|
|
||||||
|
|
||||||
type OrderSummaryProps = {
|
|
||||||
menu: Array<MenuItemWithRecipe>
|
|
||||||
quantities: Record<number, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrderSummary = ({ menu, quantities }: OrderSummaryProps) => {
|
|
||||||
const lines = menu
|
|
||||||
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
|
|
||||||
.filter(({ quantity }) => quantity > 0)
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className={styles.empty}>
|
|
||||||
Your order is empty — add something from the menu.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCents = lines.reduce(
|
|
||||||
(sum, { item, quantity }) => sum + item.priceCents * quantity,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.summary}>
|
|
||||||
<ul className={styles.lines}>
|
|
||||||
{lines.map(({ item, quantity }) => (
|
|
||||||
<li key={item.id} className={styles.line}>
|
|
||||||
<span>
|
|
||||||
{quantity} × {item.name}
|
|
||||||
</span>
|
|
||||||
<span>${((item.priceCents * quantity) / 100).toFixed(2)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className={styles.total}>
|
|
||||||
<span>Total</span>
|
|
||||||
<span>${(totalCents / 100).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,16 +6,71 @@
|
|||||||
padding: $space-5 $space-3;
|
padding: $space-5 $space-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
margin: 0 0 $space-3;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-primary;
|
||||||
|
|
||||||
|
.backArrow {
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backArrow {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
margin: 0 0 $space-1;
|
margin: 0 0 $space-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orderStatus {
|
.orderStatus {
|
||||||
margin: 0 0 $space-4;
|
margin: 0 0 $space-1;
|
||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
text-transform: capitalize;
|
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 {
|
.error {
|
||||||
color: $color-danger;
|
color: $color-danger;
|
||||||
}
|
}
|
||||||
@@ -39,53 +94,311 @@
|
|||||||
.itemHeader {
|
.itemHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: $space-2;
|
margin-bottom: $space-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemStatus {
|
.menuButton {
|
||||||
text-transform: capitalize;
|
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;
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.steps {
|
.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;
|
margin: 0 0 $space-2;
|
||||||
padding-left: $space-4;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step {
|
// The negative margin + matching padding leaves room for the in-progress
|
||||||
&[data-status='done'] {
|
// dot's sonar ping, which the collapse animation's overflow would clip.
|
||||||
text-decoration: line-through;
|
.timelineWrapper {
|
||||||
}
|
overflow: hidden;
|
||||||
|
margin: (-$space-2);
|
||||||
|
padding: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
&[data-status='in_progress'] {
|
.bannerRow {
|
||||||
color: $color-primary;
|
display: flex;
|
||||||
font-weight: 600;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.eta {
|
.stepMarker {
|
||||||
margin: 0 0 $space-2;
|
position: relative;
|
||||||
font-size: 0.9rem;
|
display: flex;
|
||||||
color: $color-success;
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: $space-2;
|
gap: $space-2;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
button {
|
button,
|
||||||
padding: $space-1 $space-3;
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
padding: $space-2 $space-4;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,300 @@
|
|||||||
import { useRouter } from '@tanstack/react-router'
|
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
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 { OrderStatus } from '#/db/schema.ts'
|
||||||
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
||||||
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.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 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 = {
|
export type OrderStatusResult = {
|
||||||
id: number
|
id: number
|
||||||
status: OrderStatus
|
status: OrderStatus
|
||||||
|
createdAt: Date
|
||||||
items: Array<OrderItemStatusView>
|
items: Array<OrderItemStatusView>
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderStatusViewProps = {
|
type OrderStatusViewProps = {
|
||||||
status: OrderStatusResult
|
status: OrderStatusResult
|
||||||
guestAccessToken: string | undefined
|
guestAccessToken: string | undefined
|
||||||
|
backTo?: '/orders' | '/admin/orders'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OrderStatusView = ({
|
export const OrderStatusView = ({
|
||||||
status,
|
status,
|
||||||
guestAccessToken,
|
guestAccessToken,
|
||||||
|
backTo = '/orders',
|
||||||
}: OrderStatusViewProps) => {
|
}: OrderStatusViewProps) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const basket = useBasket()
|
||||||
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
|
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
|
||||||
const [error, setError] = useState<string | 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 runAction = async (orderItemId: number, action: () => Promise<void>) => {
|
const buyAgain = (item: OrderItemStatusView) => {
|
||||||
|
basket.add(item.menuItemId, item.buyAgainOptionIds)
|
||||||
|
void navigate({ to: '/basket' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAction = async (
|
||||||
|
orderItemId: number,
|
||||||
|
action: () => Promise<void>,
|
||||||
|
) => {
|
||||||
setPendingItemId(orderItemId)
|
setPendingItemId(orderItemId)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -39,41 +307,252 @@ export const OrderStatusView = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<section className={styles.page}>
|
<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>
|
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||||
<p className={styles.orderStatus}>Status: {status.status}</p>
|
<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>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
<ul className={styles.items}>
|
<ul className={styles.items}>
|
||||||
{status.items.map((item) => (
|
{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}>
|
<li key={item.id} className={styles.item}>
|
||||||
<div className={styles.itemHeader}>
|
<div className={styles.itemHeader}>
|
||||||
<span className={styles.itemName}>{item.menuItemName}</span>
|
<span className={styles.itemName}>{item.menuItemName}</span>
|
||||||
<span className={styles.itemStatus}>{item.status}</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>
|
||||||
<ol className={styles.steps}>
|
</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) => (
|
{item.steps.map((step) => (
|
||||||
<li
|
<li
|
||||||
key={step.sequence}
|
key={step.sequence}
|
||||||
className={styles.step}
|
className={styles.step}
|
||||||
data-status={step.status}
|
data-status={step.status}
|
||||||
|
aria-current={
|
||||||
|
step.status === 'in_progress' ? 'step' : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{step.machineTag}
|
<span
|
||||||
|
className={styles.stepMarker}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<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.status === 'queued' &&
|
||||||
step.queuePosition !== null &&
|
step.queuePosition !== null && (
|
||||||
` — #${step.queuePosition} in line`}
|
<span className={styles.stepMeta}>
|
||||||
|
#{step.queuePosition} in line
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</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 && (
|
{item.etaSeconds !== null && (
|
||||||
<p className={styles.eta}>
|
<EtaMessage etaSeconds={item.etaSeconds} />
|
||||||
Ready in about {Math.ceil(item.etaSeconds / 60)} min
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className={styles.actions}>
|
<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 && (
|
{item.cancellable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={styles.cancelButton}
|
||||||
disabled={pendingItemId === item.id}
|
disabled={pendingItemId === item.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void runAction(item.id, () =>
|
void runAction(item.id, () =>
|
||||||
@@ -83,27 +562,14 @@ export const OrderStatusView = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<ActionIcon name="cancel" />
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{item.status === 'ready' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={pendingItemId === item.id}
|
|
||||||
onClick={() =>
|
|
||||||
void runAction(item.id, () =>
|
|
||||||
markOrderItemPickedUp({
|
|
||||||
data: { orderItemId: item.id, guestAccessToken },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
I picked this up
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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,6 +160,13 @@ 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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,12 +180,25 @@ export const orderItemSteps = pgTable('order_item_steps', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orderItems.id),
|
.references(() => orderItems.id),
|
||||||
sequence: integer().notNull(),
|
sequence: integer().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
machineTagId: integer('machine_tag_id')
|
machineTagId: integer('machine_tag_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => machineTags.id),
|
.references(() => machineTags.id),
|
||||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
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',
|
||||||
@@ -135,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)
|
||||||
|
}
|
||||||
+106
-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,22 +128,64 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+709
-28
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, eq, inArray } from 'drizzle-orm'
|
import { and, asc, desc, eq, inArray, isNotNull, isNull, lt } from 'drizzle-orm'
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
import * as schema from '#/db/schema.ts'
|
import * as schema from '#/db/schema.ts'
|
||||||
@@ -32,6 +32,27 @@ export class OrderItemNotReadyError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class NoPickupBoxYetError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('No pickup box is free for this item yet')
|
||||||
|
this.name = 'NoPickupBoxYetError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PickupWindowExpiredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('This item was not picked up in time and has been discarded')
|
||||||
|
this.name = 'PickupWindowExpiredError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PickupBoxNotOpenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Open the pickup box before closing it')
|
||||||
|
this.name = 'PickupBoxNotOpenError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class NoStepInProgressError extends Error {
|
export class NoStepInProgressError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('This item has no Preparation Step currently in progress')
|
super('This item has no Preparation Step currently in progress')
|
||||||
@@ -53,9 +74,19 @@ export type MenuItemWithRecipe = {
|
|||||||
priceCents: number
|
priceCents: number
|
||||||
steps: Array<{
|
steps: Array<{
|
||||||
sequence: number
|
sequence: number
|
||||||
|
name: string
|
||||||
machineTag: string
|
machineTag: string
|
||||||
estimatedDurationSeconds: number
|
estimatedDurationSeconds: number
|
||||||
}>
|
}>
|
||||||
|
options: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
priceDeltaCents: number
|
||||||
|
}>
|
||||||
|
allergens: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMenu = async (
|
export const getMenu = async (
|
||||||
@@ -67,10 +98,13 @@ export const getMenu = async (
|
|||||||
.from(schema.menuItems)
|
.from(schema.menuItems)
|
||||||
.where(eq(schema.menuItems.brandId, brandId))
|
.where(eq(schema.menuItems.brandId, brandId))
|
||||||
|
|
||||||
|
const menuItemIds = items.map((item) => item.id)
|
||||||
|
|
||||||
const steps = await db
|
const steps = await db
|
||||||
.select({
|
.select({
|
||||||
menuItemId: schema.menuItemSteps.menuItemId,
|
menuItemId: schema.menuItemSteps.menuItemId,
|
||||||
sequence: schema.menuItemSteps.sequence,
|
sequence: schema.menuItemSteps.sequence,
|
||||||
|
name: schema.menuItemSteps.name,
|
||||||
machineTag: schema.machineTags.name,
|
machineTag: schema.machineTags.name,
|
||||||
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
||||||
})
|
})
|
||||||
@@ -79,14 +113,33 @@ export const getMenu = async (
|
|||||||
schema.machineTags,
|
schema.machineTags,
|
||||||
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
||||||
)
|
)
|
||||||
.where(
|
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
|
||||||
inArray(
|
|
||||||
schema.menuItemSteps.menuItemId,
|
|
||||||
items.map((item) => item.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(schema.menuItemSteps.sequence))
|
.orderBy(asc(schema.menuItemSteps.sequence))
|
||||||
|
|
||||||
|
const options =
|
||||||
|
menuItemIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(schema.menuItemOptions)
|
||||||
|
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
|
||||||
|
|
||||||
|
const itemAllergens =
|
||||||
|
menuItemIds.length === 0
|
||||||
|
? []
|
||||||
|
: 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),
|
||||||
|
)
|
||||||
|
.where(inArray(schema.menuItemAllergens.menuItemId, menuItemIds))
|
||||||
|
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -94,11 +147,18 @@ export const getMenu = async (
|
|||||||
priceCents: item.priceCents,
|
priceCents: item.priceCents,
|
||||||
steps: steps
|
steps: steps
|
||||||
.filter((step) => step.menuItemId === item.id)
|
.filter((step) => step.menuItemId === item.id)
|
||||||
.map(({ sequence, machineTag, estimatedDurationSeconds }) => ({
|
.map(({ sequence, name, machineTag, estimatedDurationSeconds }) => ({
|
||||||
sequence,
|
sequence,
|
||||||
|
name,
|
||||||
machineTag,
|
machineTag,
|
||||||
estimatedDurationSeconds,
|
estimatedDurationSeconds,
|
||||||
})),
|
})),
|
||||||
|
options: options
|
||||||
|
.filter((option) => option.menuItemId === item.id)
|
||||||
|
.map(({ id, name, priceDeltaCents }) => ({ id, name, priceDeltaCents })),
|
||||||
|
allergens: itemAllergens
|
||||||
|
.filter((allergen) => allergen.menuItemId === item.id)
|
||||||
|
.map(({ id, name }) => ({ id, name })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +259,7 @@ const initializeOrderItemRecipe = async (
|
|||||||
liveSteps.map((step) => ({
|
liveSteps.map((step) => ({
|
||||||
orderItemId,
|
orderItemId,
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTagId: step.machineTagId,
|
machineTagId: step.machineTagId,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
})),
|
})),
|
||||||
@@ -227,7 +288,7 @@ const initializeOrderItemRecipe = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PlaceOrderInput = {
|
export type PlaceOrderInput = {
|
||||||
items: Array<{ menuItemId: number }>
|
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
|
||||||
customer:
|
customer:
|
||||||
| { type: 'guest'; name: string; contact: string }
|
| { type: 'guest'; name: string; contact: string }
|
||||||
| { type: 'account'; customerId: string }
|
| { type: 'account'; customerId: string }
|
||||||
@@ -272,8 +333,39 @@ export const placeOrder = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCents = input.items.reduce(
|
const allSelectedOptionIds = [
|
||||||
(sum, item) => sum + menuItemById.get(item.menuItemId)!.priceCents,
|
...new Set(input.items.flatMap((item) => item.selectedOptionIds ?? [])),
|
||||||
|
]
|
||||||
|
const selectedOptionRows =
|
||||||
|
allSelectedOptionIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(schema.menuItemOptions)
|
||||||
|
.where(inArray(schema.menuItemOptions.id, allSelectedOptionIds))
|
||||||
|
const optionById = new Map(selectedOptionRows.map((row) => [row.id, row]))
|
||||||
|
|
||||||
|
// Resolve each Order Item's selected Options up front, validating that
|
||||||
|
// every id is a real Option belonging to that specific Menu Item — not,
|
||||||
|
// say, a Burger option applied to a Pizza — before anyone is charged.
|
||||||
|
const resolvedItems = input.items.map((item) => {
|
||||||
|
const options = (item.selectedOptionIds ?? []).map((optionId) => {
|
||||||
|
const option = optionById.get(optionId)
|
||||||
|
if (!option || option.menuItemId !== item.menuItemId) {
|
||||||
|
throw new Error(
|
||||||
|
`Option ${optionId} is not valid for menu item ${item.menuItemId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return option
|
||||||
|
})
|
||||||
|
const priceCents =
|
||||||
|
menuItemById.get(item.menuItemId)!.priceCents +
|
||||||
|
options.reduce((sum, option) => sum + option.priceDeltaCents, 0)
|
||||||
|
return { menuItemId: item.menuItemId, options, priceCents }
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCents = resolvedItems.reduce(
|
||||||
|
(sum, item) => sum + item.priceCents,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -309,22 +401,31 @@ export const placeOrder = async (
|
|||||||
|
|
||||||
const tagsToAssign = new Set<number>()
|
const tagsToAssign = new Set<number>()
|
||||||
|
|
||||||
for (const item of input.items) {
|
for (const item of resolvedItems) {
|
||||||
const menuItem = menuItemById.get(item.menuItemId)!
|
|
||||||
const [orderItem] = await tx
|
const [orderItem] = await tx
|
||||||
.insert(schema.orderItems)
|
.insert(schema.orderItems)
|
||||||
.values({
|
.values({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
menuItemId: menuItem.id,
|
menuItemId: item.menuItemId,
|
||||||
priceCents: menuItem.priceCents,
|
priceCents: item.priceCents,
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
if (item.options.length > 0) {
|
||||||
|
await tx.insert(schema.orderItemOptions).values(
|
||||||
|
item.options.map((option) => ({
|
||||||
|
orderItemId: orderItem.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const step = await initializeOrderItemRecipe(
|
const step = await initializeOrderItemRecipe(
|
||||||
tx,
|
tx,
|
||||||
orderItem.id,
|
orderItem.id,
|
||||||
menuItem.id,
|
item.menuItemId,
|
||||||
)
|
)
|
||||||
tagsToAssign.add(step.machineTagId)
|
tagsToAssign.add(step.machineTagId)
|
||||||
}
|
}
|
||||||
@@ -352,7 +453,11 @@ export const placeOrder = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrderAccess = { customerId?: string; guestAccessToken?: string }
|
export type OrderAccess = {
|
||||||
|
customerId?: string
|
||||||
|
guestAccessToken?: string
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const assertOrderAccess = (
|
const assertOrderAccess = (
|
||||||
order: Pick<
|
order: Pick<
|
||||||
@@ -361,6 +466,7 @@ const assertOrderAccess = (
|
|||||||
>,
|
>,
|
||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
|
if (access.isAdmin) return
|
||||||
if (order.customerId) {
|
if (order.customerId) {
|
||||||
if (access.customerId === order.customerId) return
|
if (access.customerId === order.customerId) return
|
||||||
} else if (order.guestAccessToken) {
|
} else if (order.guestAccessToken) {
|
||||||
@@ -392,7 +498,10 @@ const updateOrderCompleteIfEverySiblingIsDone = async (
|
|||||||
.where(eq(schema.orderItems.orderId, orderId))
|
.where(eq(schema.orderItems.orderId, orderId))
|
||||||
|
|
||||||
const orderComplete = siblingItems.every(
|
const orderComplete = siblingItems.every(
|
||||||
(item) => item.status === 'picked_up' || item.status === 'cancelled',
|
(item) =>
|
||||||
|
item.status === 'picked_up' ||
|
||||||
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'discarded',
|
||||||
)
|
)
|
||||||
|
|
||||||
if (orderComplete) {
|
if (orderComplete) {
|
||||||
@@ -429,7 +538,11 @@ export const cancelOrderItem = async (
|
|||||||
if (orderItem.status === 'cancelled') {
|
if (orderItem.status === 'cancelled') {
|
||||||
throw new OrderItemNotCancellableError('it has already been cancelled')
|
throw new OrderItemNotCancellableError('it has already been cancelled')
|
||||||
}
|
}
|
||||||
if (orderItem.status === 'ready' || orderItem.status === 'picked_up') {
|
if (
|
||||||
|
orderItem.status === 'ready' ||
|
||||||
|
orderItem.status === 'picked_up' ||
|
||||||
|
orderItem.status === 'discarded'
|
||||||
|
) {
|
||||||
throw new OrderItemNotCancellableError('it is already done cooking')
|
throw new OrderItemNotCancellableError('it is already done cooking')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,14 +577,12 @@ export const cancelOrderItem = async (
|
|||||||
.returning({ id: schema.preparationStepInstances.id })
|
.returning({ id: schema.preparationStepInstances.id })
|
||||||
|
|
||||||
if (deletedSteps.length === 0) {
|
if (deletedSteps.length === 0) {
|
||||||
throw new OrderItemNotCancellableError(
|
throw new OrderItemNotCancellableError('it has already started cooking')
|
||||||
'it has already started cooking',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx
|
await tx
|
||||||
.update(schema.orderItems)
|
.update(schema.orderItems)
|
||||||
.set({ status: 'cancelled' })
|
.set({ status: 'cancelled', cancelledBy: 'customer' })
|
||||||
.where(eq(schema.orderItems.id, orderItemId))
|
.where(eq(schema.orderItems.id, orderItemId))
|
||||||
|
|
||||||
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
||||||
@@ -534,6 +645,7 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
|
|||||||
.update(schema.orderItems)
|
.update(schema.orderItems)
|
||||||
.set({ status: 'ready' })
|
.set({ status: 'ready' })
|
||||||
.where(eq(schema.orderItems.id, orderItemId))
|
.where(eq(schema.orderItems.id, orderItemId))
|
||||||
|
await assignFreePickupBoxes(tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Freeing this machine may unblock another queued Step for the same Tag.
|
// Freeing this machine may unblock another queued Step for the same Tag.
|
||||||
@@ -541,12 +653,227 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const markOrderItemPickedUp = async (
|
// A Machine reporting that the Step it's working on will take longer than
|
||||||
|
// estimated. Delays accumulate, and the extra time counts toward the ETA.
|
||||||
|
export const delayCurrentStep = async (
|
||||||
db: Db,
|
db: Db,
|
||||||
orderItemId: number,
|
orderItemId: number,
|
||||||
|
delaySeconds: number,
|
||||||
|
) => {
|
||||||
|
const currentStep = await getCurrentStepInstance(db, orderItemId)
|
||||||
|
if (!currentStep || currentStep.status !== 'in_progress') {
|
||||||
|
throw new NoStepInProgressError()
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.preparationStepInstances)
|
||||||
|
.set({ delaySeconds: currentStep.delaySeconds + delaySeconds })
|
||||||
|
.where(eq(schema.preparationStepInstances.id, currentStep.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kitchen giving up on an Order Item (e.g. a Machine fault), which unlike
|
||||||
|
// a customer cancellation is allowed at any point before it's ready — even
|
||||||
|
// mid-Step. The customer is refunded all the same.
|
||||||
|
export const cancelOrderItemByKitchen = async (
|
||||||
|
db: Db,
|
||||||
|
paymentProvider: PaymentProvider,
|
||||||
|
orderItemId: number,
|
||||||
|
) => {
|
||||||
|
const orderItem = firstRow(
|
||||||
|
await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orderItems)
|
||||||
|
.where(eq(schema.orderItems.id, orderItemId)),
|
||||||
|
)
|
||||||
|
if (!orderItem) throw new Error('Order item not found')
|
||||||
|
if (orderItem.status !== 'placed' && orderItem.status !== 'paid') {
|
||||||
|
throw new OrderItemNotCancellableError('it is no longer being prepared')
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = firstRow(
|
||||||
|
await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orders)
|
||||||
|
.where(eq(schema.orders.id, orderItem.orderId)),
|
||||||
|
)
|
||||||
|
if (!order) throw new Error('Order not found')
|
||||||
|
|
||||||
|
// Refund first, mirroring cancelOrderItem: a failed refund records nothing.
|
||||||
|
if (order.paymentChargeId) {
|
||||||
|
await paymentProvider.refund(order.paymentChargeId, orderItem.priceCents)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const removedSteps = await tx
|
||||||
|
.delete(schema.preparationStepInstances)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.preparationStepInstances.orderItemId, orderItemId),
|
||||||
|
inArray(schema.preparationStepInstances.status, [
|
||||||
|
'queued',
|
||||||
|
'in_progress',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ machineTagId: schema.preparationStepInstances.machineTagId })
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(schema.orderItems)
|
||||||
|
.set({ status: 'cancelled', cancelledBy: 'kitchen' })
|
||||||
|
.where(eq(schema.orderItems.id, orderItemId))
|
||||||
|
|
||||||
|
for (const step of removedSteps) {
|
||||||
|
await assignQueuedStepsForTag(tx, step.machineTagId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SimulatedStep = Pick<
|
||||||
|
typeof schema.preparationStepInstances.$inferSelect,
|
||||||
|
'estimatedDurationSeconds' | 'delaySeconds'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type KitchenSimulationPolicy = {
|
||||||
|
stepDurationMs: (step: SimulatedStep) => number
|
||||||
|
// What happens to a Step once its simulated duration is up.
|
||||||
|
pickOutcome: (
|
||||||
|
step: SimulatedStep,
|
||||||
|
) =>
|
||||||
|
| { type: 'finish' }
|
||||||
|
| { type: 'delay'; delaySeconds: number }
|
||||||
|
| { type: 'cancel' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plays the part of every Machine at once for the dev kitchen simulation:
|
||||||
|
// settles each in-progress Step whose simulated duration has elapsed since
|
||||||
|
// its Machine claimed it, whichever Order it belongs to — otherwise a Step
|
||||||
|
// nobody is simulating would hold its Machine forever and starve the queue.
|
||||||
|
export const runKitchenSimulationTick = async (
|
||||||
|
db: Db,
|
||||||
|
paymentProvider: PaymentProvider,
|
||||||
|
policy: KitchenSimulationPolicy,
|
||||||
|
now = new Date(),
|
||||||
|
) => {
|
||||||
|
await discardUncollectedItems(db, now)
|
||||||
|
|
||||||
|
const inProgressSteps = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.preparationStepInstances)
|
||||||
|
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
|
||||||
|
|
||||||
|
const elapsedSteps = inProgressSteps.filter(
|
||||||
|
(step) =>
|
||||||
|
step.startedAt !== null &&
|
||||||
|
now.getTime() - step.startedAt.getTime() >= policy.stepDurationMs(step),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const step of elapsedSteps) {
|
||||||
|
const outcome = policy.pickOutcome(step)
|
||||||
|
if (outcome.type === 'delay') {
|
||||||
|
await delayCurrentStep(db, step.orderItemId, outcome.delaySeconds)
|
||||||
|
} else if (outcome.type === 'cancel') {
|
||||||
|
await cancelOrderItemByKitchen(db, paymentProvider, step.orderItemId)
|
||||||
|
} else {
|
||||||
|
await advanceOrderItemStep(db, step.orderItemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hands each free Pickup Box to a ready Order Item still waiting for one,
|
||||||
|
// oldest item first.
|
||||||
|
const assignFreePickupBoxes = async (db: DbOrTx) => {
|
||||||
|
const occupiedBoxIds = new Set(
|
||||||
|
(
|
||||||
|
await db
|
||||||
|
.select({ pickupBoxId: schema.orderItems.pickupBoxId })
|
||||||
|
.from(schema.orderItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.orderItems.status, 'ready'),
|
||||||
|
isNotNull(schema.orderItems.pickupBoxId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).map((row) => row.pickupBoxId),
|
||||||
|
)
|
||||||
|
const freeBoxes = (
|
||||||
|
await db
|
||||||
|
.select()
|
||||||
|
.from(schema.pickupBoxes)
|
||||||
|
.orderBy(asc(schema.pickupBoxes.number))
|
||||||
|
).filter((box) => !occupiedBoxIds.has(box.id))
|
||||||
|
if (freeBoxes.length === 0) return
|
||||||
|
|
||||||
|
const waitingItems = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orderItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.orderItems.status, 'ready'),
|
||||||
|
isNull(schema.orderItems.pickupBoxId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.orderItems.id))
|
||||||
|
.limit(freeBoxes.length)
|
||||||
|
|
||||||
|
for (const [index, item] of waitingItems.entries()) {
|
||||||
|
await db
|
||||||
|
.update(schema.orderItems)
|
||||||
|
.set({ pickupBoxId: freeBoxes[index].id, placedInBoxAt: new Date() })
|
||||||
|
.where(eq(schema.orderItems.id, item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PICKUP_WINDOW_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
// Empties every box whose item has sat uncollected past the pickup window,
|
||||||
|
// without a refund, and hands the freed boxes on. An open box is skipped: its
|
||||||
|
// customer is taking the item out right now. Nothing runs this on a schedule
|
||||||
|
// yet, so reads and box actions call it first to bring state up to date.
|
||||||
|
export const discardUncollectedItems = async (db: DbOrTx, now = new Date()) => {
|
||||||
|
const expiredItems = await db
|
||||||
|
.select({
|
||||||
|
id: schema.orderItems.id,
|
||||||
|
orderId: schema.orderItems.orderId,
|
||||||
|
})
|
||||||
|
.from(schema.orderItems)
|
||||||
|
.innerJoin(
|
||||||
|
schema.pickupBoxes,
|
||||||
|
eq(schema.orderItems.pickupBoxId, schema.pickupBoxes.id),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.orderItems.status, 'ready'),
|
||||||
|
eq(schema.pickupBoxes.isOpen, false),
|
||||||
|
lt(
|
||||||
|
schema.orderItems.placedInBoxAt,
|
||||||
|
new Date(now.getTime() - PICKUP_WINDOW_MS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (expiredItems.length === 0) return
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.orderItems)
|
||||||
|
.set({ status: 'discarded' })
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
schema.orderItems.id,
|
||||||
|
expiredItems.map((item) => item.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for (const orderId of new Set(expiredItems.map((item) => item.orderId))) {
|
||||||
|
await updateOrderCompleteIfEverySiblingIsDone(db, orderId)
|
||||||
|
}
|
||||||
|
await assignFreePickupBoxes(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPickupBoxForReadyItem = async (
|
||||||
|
tx: Tx,
|
||||||
|
orderItemId: number,
|
||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const orderItem = firstRow(
|
const orderItem = firstRow(
|
||||||
await tx
|
await tx
|
||||||
.select()
|
.select()
|
||||||
@@ -564,14 +891,63 @@ export const markOrderItemPickedUp = async (
|
|||||||
if (!order) throw new Error('Order not found')
|
if (!order) throw new Error('Order not found')
|
||||||
assertOrderAccess(order, access)
|
assertOrderAccess(order, access)
|
||||||
|
|
||||||
|
if (orderItem.status === 'discarded') throw new PickupWindowExpiredError()
|
||||||
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
|
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
|
||||||
|
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
|
||||||
|
|
||||||
|
const box = firstRow(
|
||||||
|
await tx
|
||||||
|
.select()
|
||||||
|
.from(schema.pickupBoxes)
|
||||||
|
.where(eq(schema.pickupBoxes.id, orderItem.pickupBoxId)),
|
||||||
|
)
|
||||||
|
if (!box) throw new Error('Pickup box not found')
|
||||||
|
return { orderItem, box }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand-in for unlocking the physical box door until hardware integration
|
||||||
|
// lands, like the kitchen simulation does for Machines.
|
||||||
|
export const openPickupBox = async (
|
||||||
|
db: Db,
|
||||||
|
orderItemId: number,
|
||||||
|
access: OrderAccess,
|
||||||
|
) => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await discardUncollectedItems(tx)
|
||||||
|
const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
|
||||||
|
await tx
|
||||||
|
.update(schema.pickupBoxes)
|
||||||
|
.set({ isOpen: true })
|
||||||
|
.where(eq(schema.pickupBoxes.id, box.id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing the box after taking the item out is what counts as picking it up.
|
||||||
|
export const closePickupBox = async (
|
||||||
|
db: Db,
|
||||||
|
orderItemId: number,
|
||||||
|
access: OrderAccess,
|
||||||
|
) => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await discardUncollectedItems(tx)
|
||||||
|
const { orderItem, box } = await getPickupBoxForReadyItem(
|
||||||
|
tx,
|
||||||
|
orderItemId,
|
||||||
|
access,
|
||||||
|
)
|
||||||
|
if (!box.isOpen) throw new PickupBoxNotOpenError()
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(schema.pickupBoxes)
|
||||||
|
.set({ isOpen: false })
|
||||||
|
.where(eq(schema.pickupBoxes.id, box.id))
|
||||||
await tx
|
await tx
|
||||||
.update(schema.orderItems)
|
.update(schema.orderItems)
|
||||||
.set({ status: 'picked_up' })
|
.set({ status: 'picked_up', pickedUpAt: new Date() })
|
||||||
.where(eq(schema.orderItems.id, orderItemId))
|
.where(eq(schema.orderItems.id, orderItemId))
|
||||||
|
|
||||||
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
|
||||||
|
await assignFreePickupBoxes(tx)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,14 +955,29 @@ export type OrderItemStatusView = {
|
|||||||
id: number
|
id: number
|
||||||
menuItemName: string
|
menuItemName: string
|
||||||
status: schema.OrderItemStatus
|
status: schema.OrderItemStatus
|
||||||
|
cancelledBy: schema.OrderItemCancelledBy | null
|
||||||
cancellable: boolean
|
cancellable: boolean
|
||||||
steps: Array<{
|
steps: Array<{
|
||||||
sequence: number
|
sequence: number
|
||||||
|
name: string
|
||||||
machineTag: string
|
machineTag: string
|
||||||
estimatedDurationSeconds: number
|
estimatedDurationSeconds: number
|
||||||
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
||||||
queuePosition: number | null
|
queuePosition: number | null
|
||||||
}>
|
}>
|
||||||
|
options: Array<{ name: string; priceDeltaCents: number }>
|
||||||
|
menuItemId: number
|
||||||
|
// The Menu Item's current Options matching this item's by name — editing a
|
||||||
|
// Menu Item recreates its Options under new ids, so the ids at order time
|
||||||
|
// can't be reused. Options since removed from the menu are left out.
|
||||||
|
buyAgainOptionIds: Array<number>
|
||||||
|
// The box a ready item waits in, still reported once it's picked up or
|
||||||
|
// discarded (with isOpen false, since the box may since have gone to
|
||||||
|
// another item).
|
||||||
|
pickupBox: { number: number; isOpen: boolean } | null
|
||||||
|
pickedUpAt: Date | null
|
||||||
|
// When the box will be emptied if the item is still there.
|
||||||
|
pickupDeadline: Date | null
|
||||||
etaSeconds: number | null
|
etaSeconds: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,6 +986,8 @@ export const getOrderStatus = async (
|
|||||||
orderId: number,
|
orderId: number,
|
||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
|
await discardUncollectedItems(db)
|
||||||
|
|
||||||
const order = firstRow(
|
const order = firstRow(
|
||||||
await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)),
|
await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)),
|
||||||
)
|
)
|
||||||
@@ -618,6 +1011,32 @@ export const getOrderStatus = async (
|
|||||||
).map((row) => [row.id, row]),
|
).map((row) => [row.id, row]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const optionRows =
|
||||||
|
orderItemIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orderItemOptions)
|
||||||
|
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
|
||||||
|
|
||||||
|
const pickupBoxIds = items
|
||||||
|
.map((item) => item.pickupBoxId)
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
const pickupBoxesById = new Map(
|
||||||
|
(pickupBoxIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(schema.pickupBoxes)
|
||||||
|
.where(inArray(schema.pickupBoxes.id, pickupBoxIds))
|
||||||
|
).map((box) => [box.id, box]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentOptionRows = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.menuItemOptions)
|
||||||
|
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
|
||||||
|
|
||||||
const recipeRows =
|
const recipeRows =
|
||||||
orderItemIds.length === 0
|
orderItemIds.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -625,6 +1044,7 @@ export const getOrderStatus = async (
|
|||||||
.select({
|
.select({
|
||||||
orderItemId: schema.orderItemSteps.orderItemId,
|
orderItemId: schema.orderItemSteps.orderItemId,
|
||||||
sequence: schema.orderItemSteps.sequence,
|
sequence: schema.orderItemSteps.sequence,
|
||||||
|
name: schema.orderItemSteps.name,
|
||||||
machineTag: schema.machineTags.name,
|
machineTag: schema.machineTags.name,
|
||||||
machineTagId: schema.orderItemSteps.machineTagId,
|
machineTagId: schema.orderItemSteps.machineTagId,
|
||||||
estimatedDurationSeconds:
|
estimatedDurationSeconds:
|
||||||
@@ -710,6 +1130,7 @@ export const getOrderStatus = async (
|
|||||||
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
|
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
@@ -720,6 +1141,7 @@ export const getOrderStatus = async (
|
|||||||
if (instance.status === 'done') {
|
if (instance.status === 'done') {
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'done' as const,
|
status: 'done' as const,
|
||||||
@@ -733,12 +1155,15 @@ export const getOrderStatus = async (
|
|||||||
: 0
|
: 0
|
||||||
if (etaSeconds !== null) {
|
if (etaSeconds !== null) {
|
||||||
etaSeconds += Math.max(
|
etaSeconds += Math.max(
|
||||||
step.estimatedDurationSeconds - elapsedSeconds,
|
step.estimatedDurationSeconds +
|
||||||
|
instance.delaySeconds -
|
||||||
|
elapsedSeconds,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'in_progress' as const,
|
status: 'in_progress' as const,
|
||||||
@@ -765,6 +1190,7 @@ export const getOrderStatus = async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sequence: step.sequence,
|
sequence: step.sequence,
|
||||||
|
name: step.name,
|
||||||
machineTag: step.machineTag,
|
machineTag: step.machineTag,
|
||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
status: 'queued' as const,
|
status: 'queued' as const,
|
||||||
@@ -775,23 +1201,56 @@ export const getOrderStatus = async (
|
|||||||
if (
|
if (
|
||||||
item.status === 'ready' ||
|
item.status === 'ready' ||
|
||||||
item.status === 'picked_up' ||
|
item.status === 'picked_up' ||
|
||||||
item.status === 'cancelled'
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'discarded'
|
||||||
) {
|
) {
|
||||||
etaSeconds = null
|
etaSeconds = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentInstance = instances.find((row) => row.status !== 'done')
|
const currentInstance = instances.find((row) => row.status !== 'done')
|
||||||
|
const options = optionRows.filter(
|
||||||
|
(option) => option.orderItemId === item.id,
|
||||||
|
)
|
||||||
|
const pickupBox =
|
||||||
|
item.pickupBoxId !== null
|
||||||
|
? pickupBoxesById.get(item.pickupBoxId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
menuItemName: menuItem?.name ?? '',
|
menuItemName: menuItem?.name ?? '',
|
||||||
status: item.status,
|
status: item.status,
|
||||||
|
cancelledBy: item.cancelledBy,
|
||||||
cancellable:
|
cancellable:
|
||||||
item.status !== 'cancelled' &&
|
item.status !== 'cancelled' &&
|
||||||
item.status !== 'ready' &&
|
item.status !== 'ready' &&
|
||||||
item.status !== 'picked_up' &&
|
item.status !== 'picked_up' &&
|
||||||
|
item.status !== 'discarded' &&
|
||||||
currentInstance?.status === 'queued',
|
currentInstance?.status === 'queued',
|
||||||
steps,
|
steps,
|
||||||
|
options: options.map(({ name, priceDeltaCents }) => ({
|
||||||
|
name,
|
||||||
|
priceDeltaCents,
|
||||||
|
})),
|
||||||
|
menuItemId: item.menuItemId,
|
||||||
|
buyAgainOptionIds: currentOptionRows
|
||||||
|
.filter(
|
||||||
|
(current) =>
|
||||||
|
current.menuItemId === item.menuItemId &&
|
||||||
|
options.some((option) => option.name === current.name),
|
||||||
|
)
|
||||||
|
.map((current) => current.id),
|
||||||
|
pickupBox: pickupBox
|
||||||
|
? {
|
||||||
|
number: pickupBox.number,
|
||||||
|
isOpen: item.status === 'ready' && pickupBox.isOpen,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
pickedUpAt: item.pickedUpAt,
|
||||||
|
pickupDeadline:
|
||||||
|
item.status === 'ready' && item.placedInBoxAt
|
||||||
|
? new Date(item.placedInBoxAt.getTime() + PICKUP_WINDOW_MS)
|
||||||
|
: null,
|
||||||
etaSeconds,
|
etaSeconds,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -799,6 +1258,228 @@ export const getOrderStatus = async (
|
|||||||
return {
|
return {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
|
createdAt: order.createdAt,
|
||||||
items: itemViews,
|
items: itemViews,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a customer would say their Order is doing right now, derived from its
|
||||||
|
// Order Items. The Order's own status can't say this: it stays "paid" while
|
||||||
|
// items cook and only turns "complete" once every item is picked up or
|
||||||
|
// cancelled.
|
||||||
|
export const getOrderProgress = (
|
||||||
|
items: Array<{ status: schema.OrderItemStatus; started: boolean }>,
|
||||||
|
) => {
|
||||||
|
const activeItems = items.filter(
|
||||||
|
(item) => item.status !== 'cancelled' && item.status !== 'discarded',
|
||||||
|
)
|
||||||
|
const activeStatuses = activeItems.map((item) => item.status)
|
||||||
|
if (activeStatuses.length === 0) {
|
||||||
|
return items.some((item) => item.status === 'discarded')
|
||||||
|
? 'discarded'
|
||||||
|
: 'cancelled'
|
||||||
|
}
|
||||||
|
if (activeStatuses.every((status) => status === 'picked_up'))
|
||||||
|
return 'picked_up'
|
||||||
|
if (
|
||||||
|
activeStatuses.every(
|
||||||
|
(status) => status === 'ready' || status === 'picked_up',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'ready'
|
||||||
|
}
|
||||||
|
if (activeStatuses.includes('ready')) return 'partly_ready'
|
||||||
|
if (activeItems.some((item) => item.started || item.status === 'picked_up')) {
|
||||||
|
return 'preparing'
|
||||||
|
}
|
||||||
|
return 'not_started'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderProgress = ReturnType<typeof getOrderProgress>
|
||||||
|
|
||||||
|
export type CustomerOrderSummary = {
|
||||||
|
id: number
|
||||||
|
status: schema.OrderStatus
|
||||||
|
progress: OrderProgress
|
||||||
|
createdAt: Date
|
||||||
|
totalCents: number
|
||||||
|
items: Array<{ menuItemName: string; quantity: number }>
|
||||||
|
// Only set for a guest Order — its detail page needs this back as a
|
||||||
|
// search param to prove access, since there's no session to check.
|
||||||
|
guestAccessToken: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const summarizeOrders = async (
|
||||||
|
db: Db,
|
||||||
|
orders: Array<typeof schema.orders.$inferSelect>,
|
||||||
|
): Promise<Array<CustomerOrderSummary>> => {
|
||||||
|
if (orders.length === 0) return []
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select({
|
||||||
|
id: schema.orderItems.id,
|
||||||
|
orderId: schema.orderItems.orderId,
|
||||||
|
menuItemName: schema.menuItems.name,
|
||||||
|
priceCents: schema.orderItems.priceCents,
|
||||||
|
status: schema.orderItems.status,
|
||||||
|
})
|
||||||
|
.from(schema.orderItems)
|
||||||
|
.innerJoin(
|
||||||
|
schema.menuItems,
|
||||||
|
eq(schema.orderItems.menuItemId, schema.menuItems.id),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
schema.orderItems.orderId,
|
||||||
|
orders.map((order) => order.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const startedSteps = await db
|
||||||
|
.selectDistinct({
|
||||||
|
orderItemId: schema.preparationStepInstances.orderItemId,
|
||||||
|
})
|
||||||
|
.from(schema.preparationStepInstances)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
schema.preparationStepInstances.orderItemId,
|
||||||
|
items.map((item) => item.id),
|
||||||
|
),
|
||||||
|
inArray(schema.preparationStepInstances.status, [
|
||||||
|
'in_progress',
|
||||||
|
'done',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const startedItemIds = new Set(startedSteps.map((row) => row.orderItemId))
|
||||||
|
|
||||||
|
return orders.map((order) => {
|
||||||
|
const orderItems = items.filter((item) => item.orderId === order.id)
|
||||||
|
|
||||||
|
const quantityByMenuItemName = new Map<string, number>()
|
||||||
|
for (const item of orderItems) {
|
||||||
|
quantityByMenuItemName.set(
|
||||||
|
item.menuItemName,
|
||||||
|
(quantityByMenuItemName.get(item.menuItemName) ?? 0) + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
status: order.status,
|
||||||
|
progress: getOrderProgress(
|
||||||
|
orderItems.map((item) => ({
|
||||||
|
status: item.status,
|
||||||
|
started: startedItemIds.has(item.id),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0),
|
||||||
|
items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({
|
||||||
|
menuItemName,
|
||||||
|
quantity,
|
||||||
|
})),
|
||||||
|
guestAccessToken: order.guestAccessToken,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A signed-in customer's own Order history — guest Orders aren't included
|
||||||
|
// since they have no account to list under, only a per-Order access token
|
||||||
|
// (see getGuestOrderSummaries for how a guest's own history is looked up).
|
||||||
|
export const getOrdersForCustomer = async (
|
||||||
|
db: Db,
|
||||||
|
customerId: string,
|
||||||
|
): Promise<Array<CustomerOrderSummary>> => {
|
||||||
|
await discardUncollectedItems(db)
|
||||||
|
const orders = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orders)
|
||||||
|
.where(eq(schema.orders.customerId, customerId))
|
||||||
|
.orderBy(desc(schema.orders.createdAt))
|
||||||
|
|
||||||
|
return summarizeOrders(db, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when an account is deleted. orders.customerId is a soft reference
|
||||||
|
// to better-auth's user table (no DB-level FK), so deleting the user row
|
||||||
|
// would otherwise just leave those orders pointing at an id that no longer
|
||||||
|
// exists. Detaching them instead keeps the Order history intact for
|
||||||
|
// kitchen/admin purposes, just no longer attributed to an account.
|
||||||
|
export const detachCustomerOrders = async (
|
||||||
|
db: Db,
|
||||||
|
customerId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db
|
||||||
|
.update(schema.orders)
|
||||||
|
.set({ customerId: null })
|
||||||
|
.where(eq(schema.orders.customerId, customerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminOrderSummary = CustomerOrderSummary & {
|
||||||
|
customer:
|
||||||
|
| { type: 'account'; customerId: string }
|
||||||
|
| { type: 'guest'; name: string; contact: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every Order across every customer, for the admin dashboard — unlike
|
||||||
|
// getOrdersForCustomer/getGuestOrderSummaries, this isn't scoped to one
|
||||||
|
// customer's own access, so it must only ever be called once the caller has
|
||||||
|
// already been checked for admin access.
|
||||||
|
export const getAllOrders = async (
|
||||||
|
db: Db,
|
||||||
|
): Promise<Array<AdminOrderSummary>> => {
|
||||||
|
await discardUncollectedItems(db)
|
||||||
|
const orders = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orders)
|
||||||
|
.orderBy(desc(schema.orders.createdAt))
|
||||||
|
|
||||||
|
const summaries = await summarizeOrders(db, orders)
|
||||||
|
const ordersById = new Map(orders.map((order) => [order.id, order]))
|
||||||
|
|
||||||
|
return summaries.map((summary) => {
|
||||||
|
const order = ordersById.get(summary.id)!
|
||||||
|
const customer = order.customerId
|
||||||
|
? ({ type: 'account', customerId: order.customerId } as const)
|
||||||
|
: ({
|
||||||
|
type: 'guest',
|
||||||
|
name: order.guestName ?? 'Guest',
|
||||||
|
contact: order.guestContact ?? '',
|
||||||
|
} as const)
|
||||||
|
return { ...summary, customer }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
|
||||||
|
|
||||||
|
// A guest's Order history, looked up from the (orderId, guestAccessToken)
|
||||||
|
// pairs their browser remembered locally after each placement — there's no
|
||||||
|
// account to query by, so each ref's token stands in for one. A ref whose
|
||||||
|
// token doesn't match (stale, tampered, or the Order somehow isn't a guest
|
||||||
|
// Order) is silently dropped rather than failing the whole lookup.
|
||||||
|
export const getGuestOrderSummaries = async (
|
||||||
|
db: Db,
|
||||||
|
refs: Array<GuestOrderRef>,
|
||||||
|
): Promise<Array<CustomerOrderSummary>> => {
|
||||||
|
if (refs.length === 0) return []
|
||||||
|
await discardUncollectedItems(db)
|
||||||
|
|
||||||
|
const tokenByOrderId = new Map(
|
||||||
|
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const orders = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orders)
|
||||||
|
.where(inArray(schema.orders.id, [...tokenByOrderId.keys()]))
|
||||||
|
const authorizedOrders = orders.filter(
|
||||||
|
(order) =>
|
||||||
|
order.guestAccessToken !== null &&
|
||||||
|
tokenByOrderId.get(order.id) === order.guestAccessToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
const summaries = await summarizeOrders(db, authorizedOrders)
|
||||||
|
return summaries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
+334
-3
@@ -10,7 +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 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({
|
||||||
@@ -18,11 +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({
|
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
|
||||||
id: '/orders/$orderId',
|
id: '/orders/$orderId',
|
||||||
path: '/orders/$orderId',
|
path: '/orders/$orderId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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/$',
|
||||||
@@ -31,31 +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
|
'/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
|
'/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
|
'/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: '/' | '/orders/$orderId' | '/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: '/' | '/orders/$orderId' | '/api/auth/$'
|
to:
|
||||||
id: '__root__' | '/' | '/orders/$orderId' | '/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
|
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
||||||
|
OrdersIndexRoute: typeof OrdersIndexRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +264,76 @@ 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': {
|
'/orders/$orderId': {
|
||||||
id: '/orders/$orderId'
|
id: '/orders/$orderId'
|
||||||
path: '/orders/$orderId'
|
path: '/orders/$orderId'
|
||||||
@@ -75,6 +341,41 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof OrdersOrderIdRouteImport
|
preLoaderRoute: typeof OrdersOrderIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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/$'
|
||||||
@@ -85,9 +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,
|
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;
|
||||||
|
}
|
||||||
+46
-19
@@ -1,34 +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 { 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'
|
||||||
|
|
||||||
import appCss from '../styles.css?url'
|
|
||||||
import '../styles/globals.scss'
|
import '../styles/globals.scss'
|
||||||
|
import styles from './__root.module.scss'
|
||||||
|
|
||||||
const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
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: [
|
||||||
{
|
{
|
||||||
@@ -43,9 +64,15 @@ export const Route = createRootRoute({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
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,
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// Orders is the only admin section today — this is the stable entry point
|
||||||
|
// the account menu's "Administration" link points at, so adding more
|
||||||
|
// sections later doesn't mean touching the menu again. /admin/orders itself
|
||||||
|
// still enforces the admin check.
|
||||||
|
export const Route = createFileRoute('/admin/')({
|
||||||
|
loader: () => {
|
||||||
|
throw redirect({ to: '/admin/orders' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $space-3;
|
||||||
|
margin-bottom: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshButton {
|
||||||
|
padding: $space-1 $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSection {
|
||||||
|
margin-bottom: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $space-2;
|
||||||
|
margin-bottom: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagName {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagQueue {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machines {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machineInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machineName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machineCurrent {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px $space-2;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusIdle {
|
||||||
|
background: color-mix(in srgb, $color-success 12%, transparent);
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBusy {
|
||||||
|
background: color-mix(in srgb, $color-primary 12%, transparent);
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import type { KitchenStatus } from '#/domain/kitchen.ts'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { getKitchenStatus } from '#/server/kitchen.ts'
|
||||||
|
|
||||||
|
import styles from './kitchen.module.scss'
|
||||||
|
|
||||||
|
const formatStartedAt = (startedAt: Date | null) =>
|
||||||
|
startedAt
|
||||||
|
? startedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
: 'just now'
|
||||||
|
|
||||||
|
const groupByTag = (machines: KitchenStatus['machines']) => {
|
||||||
|
const byTag = new Map<string, KitchenStatus['machines']>()
|
||||||
|
for (const machine of machines) {
|
||||||
|
byTag.set(machine.tagName, [...(byTag.get(machine.tagName) ?? []), machine])
|
||||||
|
}
|
||||||
|
return byTag
|
||||||
|
}
|
||||||
|
|
||||||
|
const KitchenRoute = () => {
|
||||||
|
const { machines, queues } = Route.useLoaderData()
|
||||||
|
const router = useRouter()
|
||||||
|
const machinesByTag = groupByTag(machines)
|
||||||
|
const queueByTag = new Map(queues.map((queue) => [queue.tagName, queue]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.heading}>Kitchen</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.refreshButton}
|
||||||
|
onClick={() => void router.invalidate()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{machines.length === 0 ? (
|
||||||
|
<p className={styles.empty}>No machines are configured.</p>
|
||||||
|
) : (
|
||||||
|
[...machinesByTag.entries()].map(([tagName, tagMachines]) => {
|
||||||
|
const queue = queueByTag.get(tagName)
|
||||||
|
return (
|
||||||
|
<div key={tagName} className={styles.tagSection}>
|
||||||
|
<div className={styles.tagHeader}>
|
||||||
|
<h2 className={styles.tagName}>{tagName}</h2>
|
||||||
|
{queue && (
|
||||||
|
<span className={styles.tagQueue}>
|
||||||
|
{queue.idleMachineCount} idle · {queue.busyMachineCount}{' '}
|
||||||
|
busy
|
||||||
|
{queue.queuedStepCount > 0 &&
|
||||||
|
` · ${queue.queuedStepCount} waiting`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className={styles.machines}>
|
||||||
|
{tagMachines.map((machine) => (
|
||||||
|
<li key={machine.id} className={styles.machine}>
|
||||||
|
<div className={styles.machineInfo}>
|
||||||
|
<span className={styles.machineName}>
|
||||||
|
{machine.name}
|
||||||
|
</span>
|
||||||
|
{machine.current && (
|
||||||
|
<span className={styles.machineCurrent}>
|
||||||
|
{machine.current.menuItemName} ·{' '}
|
||||||
|
{machine.current.stepName} · Order #
|
||||||
|
{machine.current.orderId} · since{' '}
|
||||||
|
{formatStartedAt(machine.current.startedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`${styles.status} ${
|
||||||
|
machine.status === 'busy'
|
||||||
|
? styles.statusBusy
|
||||||
|
: styles.statusIdle
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{machine.status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/kitchen')({
|
||||||
|
loader: () => withAdminGuard(() => getKitchenStatus()),
|
||||||
|
component: KitchenRoute,
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createFileRoute, notFound, useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import {
|
||||||
|
getAdminMenu,
|
||||||
|
getAllergenOptions,
|
||||||
|
getMachineTagOptions,
|
||||||
|
updateMenuItem,
|
||||||
|
} from '#/server/menu-admin.ts'
|
||||||
|
|
||||||
|
const EditMenuItemRoute = () => {
|
||||||
|
const { item, machineTags, allergenOptions } = Route.useLoaderData()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Edit {item.name}</h1>
|
||||||
|
<MenuItemForm
|
||||||
|
machineTags={machineTags}
|
||||||
|
allergenOptions={allergenOptions}
|
||||||
|
initialItem={item}
|
||||||
|
submitLabel="Save changes"
|
||||||
|
onSubmit={async (input) => {
|
||||||
|
await updateMenuItem({ data: { menuItemId: item.id, ...input } })
|
||||||
|
await navigate({ to: '/admin/menu', viewTransition: false })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/menu/$menuItemId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const [items, machineTags, allergenOptions] = await withAdminGuard(() =>
|
||||||
|
Promise.all([
|
||||||
|
getAdminMenu(),
|
||||||
|
getMachineTagOptions(),
|
||||||
|
getAllergenOptions(),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const item = items.find((i) => i.id === Number(params.menuItemId))
|
||||||
|
if (!item) throw notFound()
|
||||||
|
return { item, machineTags, allergenOptions }
|
||||||
|
},
|
||||||
|
component: EditMenuItemRoute,
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user