diff --git a/CLAUDE.md b/CLAUDE.md index 67b2fae..a7ee5d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/package-lock.json b/package-lock.json index 3f7db8f..c34c39c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 64cd0fe..55d73b6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,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 +63,4 @@ "lightningcss" ] } -} \ No newline at end of file +} diff --git a/src/components/ordering/CheckoutSection.module.scss b/src/components/ordering/CheckoutSection.module.scss new file mode 100644 index 0000000..8781ef5 --- /dev/null +++ b/src/components/ordering/CheckoutSection.module.scss @@ -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; +} diff --git a/src/components/ordering/CheckoutSection.tsx b/src/components/ordering/CheckoutSection.tsx new file mode 100644 index 0000000..46e7c7c --- /dev/null +++ b/src/components/ordering/CheckoutSection.tsx @@ -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(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 ( +
+ + {error &&

{error}

} +
+ ) + } + + return ( +
{ + event.preventDefault() + void submit({ type: 'guest', name: guestName, contact: guestContact }) + }} + > + + + + {error &&

{error}

} +
+ ) +} diff --git a/src/components/ordering/MenuList.module.scss b/src/components/ordering/MenuList.module.scss new file mode 100644 index 0000000..b62ba72 --- /dev/null +++ b/src/components/ordering/MenuList.module.scss @@ -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; +} diff --git a/src/components/ordering/MenuList.tsx b/src/components/ordering/MenuList.tsx new file mode 100644 index 0000000..575a304 --- /dev/null +++ b/src/components/ordering/MenuList.tsx @@ -0,0 +1,68 @@ +import type { MenuItemWithRecipe } from '#/domain/ordering.ts' + +import styles from './MenuList.module.scss' + +type MenuListProps = { + menu: Array + quantities: Record + 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) => ( +
    + {menu.map((item) => { + const quantity = quantities[item.id] ?? 0 + const totalSeconds = item.steps.reduce( + (sum, step) => sum + step.estimatedDurationSeconds, + 0, + ) + + return ( +
  • +
    +

    {item.name}

    + {item.description && ( +

    {item.description}

    + )} +

    + {formatPrice(item.priceCents)} · {formatDuration(totalSeconds)} +

    +
    +
    + + {quantity} + +
    +
  • + ) + })} +
+) diff --git a/src/components/ordering/OrderSummary.module.scss b/src/components/ordering/OrderSummary.module.scss new file mode 100644 index 0000000..228b900 --- /dev/null +++ b/src/components/ordering/OrderSummary.module.scss @@ -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; +} diff --git a/src/components/ordering/OrderSummary.tsx b/src/components/ordering/OrderSummary.tsx new file mode 100644 index 0000000..27221d0 --- /dev/null +++ b/src/components/ordering/OrderSummary.tsx @@ -0,0 +1,46 @@ +import type { MenuItemWithRecipe } from '#/domain/ordering.ts' + +import styles from './OrderSummary.module.scss' + +type OrderSummaryProps = { + menu: Array + quantities: Record +} + +export const OrderSummary = ({ menu, quantities }: OrderSummaryProps) => { + const lines = menu + .map((item) => ({ item, quantity: quantities[item.id] ?? 0 })) + .filter(({ quantity }) => quantity > 0) + + if (lines.length === 0) { + return ( +

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

+ ) + } + + const totalCents = lines.reduce( + (sum, { item, quantity }) => sum + item.priceCents * quantity, + 0, + ) + + return ( +
+
    + {lines.map(({ item, quantity }) => ( +
  • + + {quantity} × {item.name} + + ${((item.priceCents * quantity) / 100).toFixed(2)} +
  • + ))} +
+
+ Total + ${(totalCents / 100).toFixed(2)} +
+
+ ) +} diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss new file mode 100644 index 0000000..87aba2d --- /dev/null +++ b/src/components/orders/OrderStatusView.module.scss @@ -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; + } + } +} diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx new file mode 100644 index 0000000..e1842f6 --- /dev/null +++ b/src/components/orders/OrderStatusView.tsx @@ -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 +} + +type OrderStatusViewProps = { + status: OrderStatusResult + guestAccessToken: string | undefined +} + +export const OrderStatusView = ({ + status, + guestAccessToken, +}: OrderStatusViewProps) => { + const router = useRouter() + const [pendingItemId, setPendingItemId] = useState(null) + const [error, setError] = useState(null) + + const runAction = async (orderItemId: number, action: () => Promise) => { + 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 ( +
+

Order #{status.id}

+

Status: {status.status}

+ {error &&

{error}

} +
    + {status.items.map((item) => ( +
  • +
    + {item.menuItemName} + {item.status} +
    +
      + {item.steps.map((step) => ( +
    1. + {step.machineTag} + {step.status === 'queued' && + step.queuePosition !== null && + ` — #${step.queuePosition} in line`} +
    2. + ))} +
    + {item.etaSeconds !== null && ( +

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

    + )} +
    + {item.cancellable && ( + + )} + {item.status === 'ready' && ( + + )} +
    +
  • + ))} +
+
+ ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 363d6fd..8d48e3a 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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 diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 0e0ea22..db27f1f 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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 }) => ( @@ -38,7 +39,7 @@ export const Route = createRootRoute({ content: 'width=device-width, initial-scale=1', }, { - title: 'TanStack Start Starter', + title: 'Ghost Kitchen', }, ], links: [ diff --git a/src/routes/index.module.scss b/src/routes/index.module.scss new file mode 100644 index 0000000..cd148ce --- /dev/null +++ b/src/routes/index.module.scss @@ -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; +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index e9467db..e2c17cf 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,12 +1,78 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useState } from 'react' -const Home = () => ( -
-

Welcome to TanStack Start

-

- Edit src/routes/index.tsx to get started. -

-
-) +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>({}) + + 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 ( +
+

{brand.name}

+
+ + +
+
+ ) +} diff --git a/src/routes/orders/$orderId.tsx b/src/routes/orders/$orderId.tsx new file mode 100644 index 0000000..9d1cdab --- /dev/null +++ b/src/routes/orders/$orderId.tsx @@ -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) => + 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 +} diff --git a/src/server/brand.ts b/src/server/brand.ts new file mode 100644 index 0000000..f682237 --- /dev/null +++ b/src/server/brand.ts @@ -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 + }, +) diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 0000000..84ecd89 --- /dev/null +++ b/src/styles/_variables.scss @@ -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; diff --git a/src/styles/globals.scss b/src/styles/globals.scss new file mode 100644 index 0000000..e5aaceb --- /dev/null +++ b/src/styles/globals.scss @@ -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; +}