Compare commits

...
2 Commits
Author SHA1 Message Date
lukasandClaude Sonnet 5 ff8fc388c0 Add docker-compose for local Postgres and fix the setup docs
Adds docker-compose.yml (Postgres on host port 5433, to avoid clashing
with another project's container already using 5432 on this machine)
and .env.example so `docker compose up -d` + `cp .env.example .env.local`
gets a working local DB. Adds a db:seed script alongside the existing
db:* scripts.

Rewrites README's "Adding a Database" section, which still described
Better Auth's DB as optional/hypothetical even though this project
already requires Postgres for both its own data and Better Auth's
tables. Fills in CLAUDE.md's Data layer connection-details placeholder
and fixes two more stale pnpm references and a wrong migrations path
(src/db/migrations/ instead of the actual drizzle/) found nearby.

Also fixes a real bug the new docs surfaced: `npm run db:seed` didn't
load .env.local the way drizzle-kit does, so DATABASE_URL was silently
unset when run as documented. seed.ts now loads env the same way
drizzle.config.ts already does.

Verified the full documented flow end-to-end against a fresh container:
migrate, better-auth migrate, seed, dev server, real page render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 21:05:07 +02:00
lukasandClaude Sonnet 5 ea459e72b3 Add the customer ordering page
Builds the main customer-facing flow: browse the Brand's menu, add
items to a cart, and check out either as a guest (name + contact) or,
if signed in, as the account customer — both call the existing
placeOrder server function, which already distinguishes the two. On
success, navigates to a new /orders/$orderId status page showing each
Order Item's Preparation Steps, queue position, ETA, and (per the
domain rules already established) a Cancel button while cancellable
and a pickup-confirmation button once ready.

Styled with SCSS Modules per CLAUDE.md; added the sass dependency and
the src/styles/ structure it describes (globals.scss imported once in
__root.tsx, alongside the existing Tailwind stylesheet the not-yet-
mounted header-user.tsx component depends on).

Verified end-to-end against a throwaway local Postgres: menu renders
with seeded items, cart quantities update, guest checkout places a
real order, and the status page reflects a real cancellation.

Also fixes CLAUDE.md's Commands section, which documented pnpm even
though this repo only has npm (package-lock.json, no pnpm installed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:31:32 +02:00
23 changed files with 1187 additions and 61 deletions
+1
View File
@@ -0,0 +1 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
+20 -16
View File
@@ -12,17 +12,17 @@ Guidance for Claude Code when working in this repository.
- Vite
- SCSS Modules
- Drizzle ORM + PostgreSQL
- [Auth: Better Auth / Clerk / custom / none yet]
- [Package manager: pnpm / npm / bun]
- Better Auth
- npm
## Commands
```bash
pnpm dev # start dev server (http://localhost:3000)
pnpm build # production build
pnpm typecheck # tsc --noEmit
pnpm lint # eslint
pnpm test # [test runner, e.g. vitest]
npm run dev # start dev server (http://localhost:3000)
npm run build # production build
npm run typecheck # tsc --noEmit
npm run lint # eslint
npm test # vitest run
```
Always run `typecheck` and `lint` before considering a task done. If `build`
@@ -44,12 +44,13 @@ src/
├── db/
│ ├── schema.ts # Drizzle schema definitions
│ ├── index.ts # Drizzle client instance
│ └── migrations/ # generated SQL migrations — never hand-edit
│ └── seed.ts # sample data for local dev / tests
├── styles/
│ ├── globals.scss # global styles, resets
│ └── _variables.scss # shared SCSS variables/mixins (partial)
public/ # static assets
drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
drizzle/ # generated SQL migrations — never hand-edit
```
### Routing conventions
@@ -116,14 +117,17 @@ drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
- After changing `schema.ts`, generate a migration rather than editing the
database by hand:
```bash
pnpm drizzle-kit generate # create a migration from schema changes
pnpm drizzle-kit migrate # apply pending migrations
npm run db:generate # create a migration from schema changes
npm run db:migrate # apply pending migrations
```
- Never hand-edit files under `db/migrations/` — they're generated and
should be treated as a history, not touched retroactively.
- [Connection details: env var name for `DATABASE_URL`, whether using a
connection pool like `postgres.js` or `node-postgres`, local dev DB setup
e.g. Docker Compose]
- Never hand-edit files under `drizzle/` (the migrations output directory,
configured in `drizzle.config.ts`) — they're generated and should be
treated as a history, not touched retroactively.
- Connection string comes from `DATABASE_URL` (see `.env.example`), read via
`drizzle-orm/node-postgres`. Local dev DB is a `docker-compose.yml`
Postgres container — see the README's "Local development database"
section. `.env.local` is picked up automatically by both the dev server
and `drizzle-kit`; no manual sourcing needed.
## Testing
@@ -134,7 +138,7 @@ drizzle.config.ts # Drizzle Kit config (schema path, out dir, dialect)
## Things to avoid
- Don't hand-edit `routeTree.gen.ts` or files under `db/migrations/`.
- Don't hand-edit `routeTree.gen.ts` or files under `drizzle/`.
- Don't run destructive Drizzle commands (`drizzle-kit push` against a
populated DB, or anything that drops columns/tables) without confirming
first — prefer `generate` + `migrate` so changes are reviewable.
+37 -28
View File
@@ -9,6 +9,10 @@ npm install
npm run dev
```
This requires a running Postgres database — see
[Local development database](#local-development-database) below before your
first run.
# Building For Production
To build this application for production:
@@ -42,39 +46,44 @@ npm run check
```
## Setting up Better Auth
## Local development database
1. Generate and set the `BETTER_AUTH_SECRET` environment variable in your `.env.local`:
```bash
npx -y @better-auth/cli secret
```
2. Visit the [Better Auth documentation](https://www.better-auth.com) to unlock the full potential of authentication in your app.
### Adding a Database (Optional)
Better Auth can work in stateless mode, but to persist user data, add a database:
```typescript
// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
// ... rest of config
});
```
Then run migrations:
This project requires a Postgres database — both for its own data (Drizzle,
see `src/db/schema.ts`) and for Better Auth's own tables (users, sessions,
etc). A `docker-compose.yml` at the repo root starts one:
```bash
npx -y @better-auth/cli migrate
docker compose up -d # starts Postgres on localhost:5433
cp .env.example .env.local # sets DATABASE_URL to match
npm run db:migrate # applies this project's Drizzle migrations
npx @better-auth/cli migrate # applies Better Auth's own tables
npm run db:seed # seeds a sample Brand, menu, and Machines
npm run dev
```
`DATABASE_URL` in `.env.local` is picked up automatically by both the dev
server and the `drizzle-kit` commands above — no other wiring needed.
After changing `src/db/schema.ts`, generate and apply a new migration rather
than editing the database by hand:
```bash
npm run db:generate
npm run db:migrate
```
## Setting up Better Auth
Generate and set the `BETTER_AUTH_SECRET` environment variable in your
`.env.local`:
```bash
npx -y @better-auth/cli secret
```
See the [Better Auth documentation](https://www.better-auth.com) for more on
configuring authentication.
## Routing
+14
View File
@@ -0,0 +1,14 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ghost_kitchen
ports:
- '5433:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
+331 -2
View File
@@ -34,6 +34,7 @@
"dotenv": "^17.3.1",
"eslint": "^9.20.0",
"prettier": "^3.8.1",
"sass": "^1.104.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
@@ -2246,6 +2247,299 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@parcel/watcher": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.6.0",
"@parcel/watcher-darwin-arm64": "2.6.0",
"@parcel/watcher-darwin-x64": "2.6.0",
"@parcel/watcher-freebsd-x64": "2.6.0",
"@parcel/watcher-linux-arm-glibc": "2.6.0",
"@parcel/watcher-linux-arm-musl": "2.6.0",
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
"@parcel/watcher-linux-arm64-musl": "2.6.0",
"@parcel/watcher-linux-x64-glibc": "2.6.0",
"@parcel/watcher-linux-x64-musl": "2.6.0",
"@parcel/watcher-win32-arm64": "2.6.0",
"@parcel/watcher-win32-x64": "2.6.0"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -7189,6 +7483,13 @@
"node": ">= 4"
}
},
"node_modules/immutable": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -7227,7 +7528,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -7247,7 +7548,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -7928,6 +8229,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.55",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
@@ -8626,6 +8934,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/sass": {
"version": "1.104.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.104.0.tgz",
"integrity": "sha512-btHMApW2bgolvClhRW8AlQJzgI9lUB3pPSofBQQT+E46GWvf9o0TVQ13SYv5riWZVFyPN+JNz3TKW9XhBlc10w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+3 -1
View File
@@ -15,6 +15,7 @@
"db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"lint": "eslint",
"format": "prettier --write . && eslint --fix",
"check": "prettier --check .",
@@ -51,6 +52,7 @@
"dotenv": "^17.3.1",
"eslint": "^9.20.0",
"prettier": "^3.8.1",
"sass": "^1.104.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
@@ -62,4 +64,4 @@
"lightningcss"
]
}
}
}
@@ -0,0 +1,45 @@
@use '../../styles/variables' as *;
.section {
display: flex;
flex-direction: column;
gap: $space-2;
margin-top: $space-3;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
}
}
.submitButton {
padding: $space-2 $space-3;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react'
import { authClient } from '#/lib/auth-client.ts'
import { placeOrder } from '#/server/orders.ts'
import styles from './CheckoutSection.module.scss'
type Customer =
| { type: 'guest'; name: string; contact: string }
| { type: 'account' }
type PlacedOrder = { id: number; guestAccessToken: string | null }
type CheckoutSectionProps = {
items: Array<{ menuItemId: number }>
disabled: boolean
onPlaced: (order: PlacedOrder) => void
}
export const CheckoutSection = ({
items,
disabled,
onPlaced,
}: CheckoutSectionProps) => {
const { data: session, isPending } = authClient.useSession()
const [guestName, setGuestName] = useState('')
const [guestContact, setGuestContact] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (customer: Customer) => {
setSubmitting(true)
setError(null)
try {
const order = await placeOrder({ data: { items, customer } })
onPlaced(order)
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Could not place the order. Please try again.',
)
} finally {
setSubmitting(false)
}
}
if (isPending) return null
if (session) {
return (
<div className={styles.section}>
<button
type="button"
className={styles.submitButton}
disabled={disabled || submitting}
onClick={() => submit({ type: 'account' })}
>
{submitting ? 'Placing order…' : `Place order as ${session.user.name}`}
</button>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
return (
<form
className={styles.section}
onSubmit={(event) => {
event.preventDefault()
void submit({ type: 'guest', name: guestName, contact: guestContact })
}}
>
<label className={styles.field}>
Name
<input
value={guestName}
onChange={(event) => setGuestName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Email or phone
<input
value={guestContact}
onChange={(event) => setGuestContact(event.target.value)}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={disabled || submitting}
>
{submitting ? 'Placing order…' : 'Place order as guest'}
</button>
{error && <p className={styles.error}>{error}</p>}
</form>
)
}
@@ -0,0 +1,68 @@
@use '../../styles/variables' as *;
.list {
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;
}
.info {
flex: 1;
}
.name {
margin: 0 0 $space-1;
font-size: 1.1rem;
}
.description {
margin: 0 0 $space-1;
color: $color-text-muted;
font-size: 0.9rem;
}
.meta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
}
.quantity {
display: flex;
align-items: center;
gap: $space-2;
}
.stepperButton {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-background;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.count {
min-width: 1.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
+68
View File
@@ -0,0 +1,68 @@
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import styles from './MenuList.module.scss'
type MenuListProps = {
menu: Array<MenuItemWithRecipe>
quantities: Record<number, number>
onAdd: (menuItemId: number) => void
onRemove: (menuItemId: number) => void
}
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatDuration = (seconds: number) => {
const minutes = Math.round(seconds / 60)
return minutes <= 1 ? '~1 min' : `~${minutes} min`
}
export const MenuList = ({
menu,
quantities,
onAdd,
onRemove,
}: MenuListProps) => (
<ul className={styles.list}>
{menu.map((item) => {
const quantity = quantities[item.id] ?? 0
const totalSeconds = item.steps.reduce(
(sum, step) => sum + step.estimatedDurationSeconds,
0,
)
return (
<li key={item.id} className={styles.item}>
<div className={styles.info}>
<h3 className={styles.name}>{item.name}</h3>
{item.description && (
<p className={styles.description}>{item.description}</p>
)}
<p className={styles.meta}>
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p>
</div>
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id)}
disabled={quantity === 0}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<span className={styles.count}>{quantity}</span>
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</li>
)
})}
</ul>
)
@@ -0,0 +1,35 @@
@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;
}
+46
View File
@@ -0,0 +1,46 @@
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,91 @@
@use '../../styles/variables' as *;
.page {
max-width: 720px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-1;
}
.orderStatus {
margin: 0 0 $space-4;
color: $color-text-muted;
text-transform: capitalize;
}
.error {
color: $color-danger;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.item {
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.itemHeader {
display: flex;
justify-content: space-between;
font-weight: 600;
margin-bottom: $space-2;
}
.itemStatus {
text-transform: capitalize;
color: $color-text-muted;
}
.steps {
margin: 0 0 $space-2;
padding-left: $space-4;
font-size: 0.85rem;
color: $color-text-muted;
}
.step {
&[data-status='done'] {
text-decoration: line-through;
}
&[data-status='in_progress'] {
color: $color-primary;
font-weight: 600;
}
}
.eta {
margin: 0 0 $space-2;
font-size: 0.9rem;
color: $color-success;
}
.actions {
display: flex;
gap: $space-2;
button {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import { useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss'
export type OrderStatusResult = {
id: number
status: OrderStatus
items: Array<OrderItemStatusView>
}
type OrderStatusViewProps = {
status: OrderStatusResult
guestAccessToken: string | undefined
}
export const OrderStatusView = ({
status,
guestAccessToken,
}: OrderStatusViewProps) => {
const router = useRouter()
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const runAction = async (orderItemId: number, action: () => Promise<void>) => {
setPendingItemId(orderItemId)
setError(null)
try {
await action()
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setPendingItemId(null)
}
}
return (
<section className={styles.page}>
<h1 className={styles.heading}>Order #{status.id}</h1>
<p className={styles.orderStatus}>Status: {status.status}</p>
{error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}>
{status.items.map((item) => (
<li key={item.id} className={styles.item}>
<div className={styles.itemHeader}>
<span className={styles.itemName}>{item.menuItemName}</span>
<span className={styles.itemStatus}>{item.status}</span>
</div>
<ol className={styles.steps}>
{item.steps.map((step) => (
<li
key={step.sequence}
className={styles.step}
data-status={step.status}
>
{step.machineTag}
{step.status === 'queued' &&
step.queuePosition !== null &&
` — #${step.queuePosition} in line`}
</li>
))}
</ol>
{item.etaSeconds !== null && (
<p className={styles.eta}>
Ready in about {Math.ceil(item.etaSeconds / 60)} min
</p>
)}
<div className={styles.actions}>
{item.cancellable && (
<button
type="button"
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
cancelOrderItem({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
Cancel
</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>
</li>
))}
</ul>
</section>
)
}
+3
View File
@@ -88,6 +88,9 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { config } = await import('dotenv')
config({ path: ['.env.local', '.env'] })
const { drizzle } = await import('drizzle-orm/node-postgres')
const db = drizzle(process.env.DATABASE_URL!, { schema })
await seedGhostKitchen(db)
+21 -3
View File
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const IndexRoute = IndexRouteImport.update({
@@ -17,6 +18,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
id: '/orders/$orderId',
path: '/orders/$orderId',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
@@ -25,27 +31,31 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/auth/$'
fullPaths: '/' | '/orders/$orderId' | '/api/auth/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/auth/$'
id: '__root__' | '/' | '/api/auth/$'
to: '/' | '/orders/$orderId' | '/api/auth/$'
id: '__root__' | '/' | '/orders/$orderId' | '/api/auth/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
}
@@ -58,6 +68,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/$orderId': {
id: '/orders/$orderId'
path: '/orders/$orderId'
fullPath: '/orders/$orderId'
preLoaderRoute: typeof OrdersOrderIdRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
@@ -70,6 +87,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
OrdersOrderIdRoute: OrdersOrderIdRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport
+2 -1
View File
@@ -3,6 +3,7 @@ import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import appCss from '../styles.css?url'
import '../styles/globals.scss'
const RootDocument = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
@@ -38,7 +39,7 @@ export const Route = createRootRoute({
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
title: 'Ghost Kitchen',
},
],
links: [
+32
View File
@@ -0,0 +1,32 @@
@use '../styles/variables' as *;
.page {
max-width: 960px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.layout {
display: grid;
grid-template-columns: 1fr;
gap: $space-4;
@media (min-width: $breakpoint-tablet) {
grid-template-columns: 2fr 1fr;
}
}
.sidebar {
display: flex;
flex-direction: column;
gap: $space-3;
align-self: start;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
+76 -10
View File
@@ -1,12 +1,78 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
const Home = () => (
<div className="p-8">
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
<p className="mt-4 text-lg">
Edit <code>src/routes/index.tsx</code> to get started.
</p>
</div>
)
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { MenuList } from '#/components/ordering/MenuList.tsx'
import { OrderSummary } from '#/components/ordering/OrderSummary.tsx'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
export const Route = createFileRoute('/')({ component: Home })
import styles from './index.module.scss'
export const Route = createFileRoute('/')({
loader: async () => {
const brand = await getBrand()
const menu = await getMenu({ data: { brandId: brand.id } })
return { brand, menu }
},
component: OrderingPage,
})
function OrderingPage() {
const { brand, menu } = Route.useLoaderData()
const navigate = useNavigate()
const [quantities, setQuantities] = useState<Record<number, number>>({})
const add = (menuItemId: number) =>
setQuantities((prev) => ({
...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
}))
const remove = (menuItemId: number) =>
setQuantities((prev) => {
const current = prev[menuItemId] ?? 0
if (current <= 1) {
const next = { ...prev }
delete next[menuItemId]
return next
}
return { ...prev, [menuItemId]: current - 1 }
})
const items = Object.entries(quantities).flatMap(([menuItemId, quantity]) =>
Array.from({ length: quantity }, () => ({
menuItemId: Number(menuItemId),
})),
)
return (
<main className={styles.page}>
<h1 className={styles.heading}>{brand.name}</h1>
<div className={styles.layout}>
<MenuList
menu={menu}
quantities={quantities}
onAdd={add}
onRemove={remove}
/>
<aside className={styles.sidebar}>
<OrderSummary menu={menu} quantities={quantities} />
<CheckoutSection
items={items}
disabled={items.length === 0}
onPlaced={(order) =>
navigate({
to: '/orders/$orderId',
params: { orderId: String(order.id) },
search: order.guestAccessToken
? { token: order.guestAccessToken }
: {},
})
}
/>
</aside>
</div>
</main>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { OrderStatusView } from '#/components/orders/OrderStatusView.tsx'
import { getOrderStatus } from '#/server/orders.ts'
const searchSchema = z.object({ token: z.string().optional() })
export const Route = createFileRoute('/orders/$orderId')({
validateSearch: (search: Record<string, unknown>) =>
searchSchema.parse(search),
loaderDeps: ({ search }) => ({ token: search.token }),
loader: ({ params, deps }) =>
getOrderStatus({
data: {
orderId: Number(params.orderId),
guestAccessToken: deps.token,
},
}),
component: OrderStatusRoute,
})
function OrderStatusRoute() {
const status = Route.useLoaderData()
const { token } = Route.useSearch()
return <OrderStatusView status={status} guestAccessToken={token} />
}
+15
View File
@@ -0,0 +1,15 @@
import { createServerFn } from '@tanstack/react-start'
import { db } from '#/db/index.ts'
import * as schema from '#/db/schema.ts'
// One Kitchen runs exactly one Brand (see docs/adr/0001-one-server-per-kitchen.md),
// so there's never a Brand to choose between — just the one row seeded for
// this deployment.
export const getBrand = createServerFn({ method: 'GET' }).handler(
async () => {
const brand = (await db.select().from(schema.brands).limit(1)).at(0)
if (!brand) throw new Error('No Brand is configured for this Kitchen')
return brand
},
)
+18
View File
@@ -0,0 +1,18 @@
$color-text: #1a1a1a;
$color-text-muted: #6b6b6b;
$color-background: #ffffff;
$color-surface: #f7f7f5;
$color-border: #e2e0dc;
$color-primary: #b5432b;
$color-primary-contrast: #ffffff;
$color-danger: #a33;
$color-success: #2f7a4f;
$space-1: 0.25rem;
$space-2: 0.5rem;
$space-3: 1rem;
$space-4: 1.5rem;
$space-5: 2.5rem;
$radius: 8px;
$breakpoint-tablet: 640px;
+23
View File
@@ -0,0 +1,23 @@
@use './variables' as *;
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: $color-text;
background: $color-background;
}
button {
font: inherit;
}