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>
This commit is contained in:
@@ -12,17 +12,17 @@ Guidance for Claude Code when working in this repository.
|
|||||||
- Vite
|
- Vite
|
||||||
- SCSS Modules
|
- SCSS Modules
|
||||||
- Drizzle ORM + PostgreSQL
|
- Drizzle ORM + PostgreSQL
|
||||||
- [Auth: Better Auth / Clerk / custom / none yet]
|
- Better Auth
|
||||||
- [Package manager: pnpm / npm / bun]
|
- npm
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # start dev server (http://localhost:3000)
|
npm run dev # start dev server (http://localhost:3000)
|
||||||
pnpm build # production build
|
npm run build # production build
|
||||||
pnpm typecheck # tsc --noEmit
|
npm run typecheck # tsc --noEmit
|
||||||
pnpm lint # eslint
|
npm run lint # eslint
|
||||||
pnpm test # [test runner, e.g. vitest]
|
npm test # vitest run
|
||||||
```
|
```
|
||||||
|
|
||||||
Always run `typecheck` and `lint` before considering a task done. If `build`
|
Always run `typecheck` and `lint` before considering a task done. If `build`
|
||||||
|
|||||||
Generated
+331
-2
@@ -34,6 +34,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"eslint": "^9.20.0",
|
"eslint": "^9.20.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"sass": "^1.104.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
@@ -2246,6 +2247,299 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -7189,6 +7483,13 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -7227,7 +7528,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -7247,7 +7548,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -7928,6 +8229,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.55",
|
"version": "2.0.55",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
|
||||||
@@ -8626,6 +8934,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -51,6 +51,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"eslint": "^9.20.0",
|
"eslint": "^9.20.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"sass": "^1.104.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
@@ -62,4 +63,4 @@
|
|||||||
"lightningcss"
|
"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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+21
-3
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
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 OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -17,6 +18,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
|
||||||
|
id: '/orders/$orderId',
|
||||||
|
path: '/orders/$orderId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||||
id: '/api/auth/$',
|
id: '/api/auth/$',
|
||||||
path: '/api/auth/$',
|
path: '/api/auth/$',
|
||||||
@@ -25,27 +31,31 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/api/auth/$'
|
fullPaths: '/' | '/orders/$orderId' | '/api/auth/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/api/auth/$'
|
to: '/' | '/orders/$orderId' | '/api/auth/$'
|
||||||
id: '__root__' | '/' | '/api/auth/$'
|
id: '__root__' | '/' | '/orders/$orderId' | '/api/auth/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +68,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/orders/$orderId': {
|
||||||
|
id: '/orders/$orderId'
|
||||||
|
path: '/orders/$orderId'
|
||||||
|
fullPath: '/orders/$orderId'
|
||||||
|
preLoaderRoute: typeof OrdersOrderIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/auth/$': {
|
'/api/auth/$': {
|
||||||
id: '/api/auth/$'
|
id: '/api/auth/$'
|
||||||
path: '/api/auth/$'
|
path: '/api/auth/$'
|
||||||
@@ -70,6 +87,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
|||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
|
|
||||||
import appCss from '../styles.css?url'
|
import appCss from '../styles.css?url'
|
||||||
|
import '../styles/globals.scss'
|
||||||
|
|
||||||
const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
const RootDocument = ({ children }: { children: React.ReactNode }) => (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -38,7 +39,7 @@ export const Route = createRootRoute({
|
|||||||
content: 'width=device-width, initial-scale=1',
|
content: 'width=device-width, initial-scale=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'TanStack Start Starter',
|
title: 'Ghost Kitchen',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -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
@@ -1,12 +1,78 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
const Home = () => (
|
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
||||||
<div className="p-8">
|
import { MenuList } from '#/components/ordering/MenuList.tsx'
|
||||||
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
|
import { OrderSummary } from '#/components/ordering/OrderSummary.tsx'
|
||||||
<p className="mt-4 text-lg">
|
import { getBrand } from '#/server/brand.ts'
|
||||||
Edit <code>src/routes/index.tsx</code> to get started.
|
import { getMenu } from '#/server/menu.ts'
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user