Compare commits

...
48 Commits
Author SHA1 Message Date
lukasandClaude Opus 5.5 757cf40358 Remove the Steps toggle from cancelled order items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:51:50 +02:00
lukasandClaude Opus 5.5 6fc315a5f2 Discard items left in their pickup box past a 15-minute window
An item's pickup window starts when it goes into its box. Once it runs
out, the item is discarded without a refund and the box is freed; a box
that's open at that moment is left alone. With no scheduler yet, reads,
box actions and the kitchen simulation check for expired items first.

The order card shows a live countdown under "Ready for pickup" and a
"Not picked up in time" banner once discarded. Also bounces the lock
icon and animates the pickup button's label changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:26:37 +02:00
lukasandClaude Opus 5.5 440f9e5696 Keep the closed box visible and tidy the order item card
- After closing, the box button stays disabled as "Box N closed" for
  5 minutes, timed from a new picked_up_at so it survives a reload
- Picked-up items keep showing their pickup box number
- Hide the steps toggle on ready and picked-up items
- Move "Buy again" and "Get help" into a dots menu in the item header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:11:47 +02:00
lukasandClaude Opus 5.5 d68806aaf8 Draw the unlocked pickup box lock as an open padlock
The shackle now morphs instead of lifting: unlocked, the left leg stays
on the body and the right leg shrinks to a stub. The icon may draw past
its viewBox so the raised arc isn't clipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:38:31 +02:00
lukasandClaude Opus 5.5 3efde72524 Make the pickup box button hold-to-open and hold-to-close
Adds a reusable HoldButton that fires after an 800ms hold (pointer or
Space/Enter) with a fill showing progress, and cancels on early release.
The pickup box button now uses it, with extra space above it on mobile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:35:05 +02:00
lukasandClaude Opus 5.5 edd34a00a7 Collect ready items from pickup boxes instead of "I picked this up"
A ready Order Item now gets the lowest-numbered free Pickup Box (or waits
for one to free up), shown as "Pickup box N" on its status. The customer
opens the box from their order and closes it again; closing marks the
item picked up and hands the box to the next waiting item.

On the order card the box button comes first, full width on mobile, with
a padlock that lifts on open and snaps shut before the button collapses
away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:31:23 +02:00
lukasandClaude Opus 5.5 ac2aa770fb Make "Buy again" add the item to the basket
The order status view now carries each item's menu item id and the ids of
its current Options matching the ordered ones by name, since editing a
Menu Item recreates its Options under new ids. Options no longer on the
menu are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:07:13 +02:00
lukasandClaude Opus 5.5 d987c71010 Drop the account name from the signed-in checkout button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:04:20 +02:00
lukasandClaude Opus 5.5 1dae53f90a Animate the ETA, hide delays, and tidy the order card actions
- Slide the "Ready in about" value when it changes (new EtaMessage)
- Drop the "Running late" banner; delays only move the ETA now
- Remove the cancel-info tooltip and its icon button
- Shorten the steps toggle to "Steps" / "Hide"
- Add icons and more padding to the order card action buttons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:02:35 +02:00
lukas 7cf286850c 🔧 update docs 2026-10-01 10:06:13 +02:00
lukasandClaude Opus 5.5 a6ac32d783 Show live order progress, delays, and kitchen cancellations
- Order cards derive their label from item progress (Not Started,
  Preparing, Partly Ready, Ready, Picked Up, Cancelled) instead of the
  order's own status; final states are coloured
- Order page: animated status banner per item (ready/picked up check,
  cancelled cross, running-late clock), steps collapse once an item is
  settled with a Show/Hide steps toggle, done steps are faded instead
  of struck through, and the per-item status badge is gone
- New Delay concept: machines can report extra time on a claimed step
  (preparation_step_instances.delay_seconds), which counts toward the ETA
- The kitchen can cancel an item at any point before it's ready, with a
  refund (order_items.cancelled_by records who cancelled)
- Dev kitchen simulation now runs server-side across every order, so a
  step nobody is watching no longer blocks the queue; each settled step
  may randomly be delayed or cancelled
- Humanize snake_case statuses in the UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:11:08 +02:00
lukasandClaude Opus 5.5 c124b0415a Add a dev-only kitchen simulation to the order status page
- "Simulate kitchen (dev)" button advances each placed/paid item's
  Preparation Steps as a Machine would, polling fresh status per item
- Each simulated step takes 10–30s, scaled from its estimated duration
- Timeline connector fills in with a Framer Motion animation when a
  step completes; finished steps and the line now use the success green

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:25:52 +02:00
lukasandClaude Sonnet 5 815939cbc6 Add account settings page and polish avatar/SSO button edge cases
- New /account route: edit name/email, change password, delete account
  (detaches the user's orders instead of leaving a dangling reference,
  since orders.customerId has no DB-level FK to better-auth's user table)
- Better Auth: allow email changes without a verification email (no
  email sending is wired up anywhere in this app) and enable account
  deletion
- Header avatar: fall back to the initials placeholder if the profile
  image fails to load, not just when there's no image URL
- Fix low-contrast "Continue with Google" button in dark mode (it used
  the page background color and a hardcoded dark-gray label, so it
  nearly disappeared against a dark background)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 22:23:50 +02:00
lukasandClaude Sonnet 5 7f72baf97d Center both stepper glyphs on the item customization page
The +/- glyphs were wrapped in a flex-centered div, but only the +
button had been converted — the - button still used a plain span and
rendered off-center. Apply the same wrapping to both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:52:48 +02:00
lukasandClaude Sonnet 5 8481032e30 Add menu item allergens, drop nav Help link, animate customizer price
- Menu items can now list allergens (shared catalog + join table),
  shown on the menu grid and item detail pages when applicable, and
  editable from the admin menu item form.
- Removed the top-nav "Help" link now that the footer links to it.
- The item customization page's price now uses the sliding
  AnimatedNumber, matching the basket line price.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:50:17 +02:00
lukasandClaude Sonnet 5 917fc275f9 Add a sticky site footer
Adds a Footer with the brand, quick links (Menu, Basket, Your orders,
Help), and a copyright line, rendered on every page. Body is now a
full-height flex column so the footer sits at the bottom of the
viewport on short pages instead of floating right under the content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:34:04 +02:00
lukasandClaude Sonnet 5 a8e655a810 Add a Help page with FAQ and contact form
Adds a /help route with an animated FAQ accordion and a contact form
(persisted via a new contact_messages table). The per-order-item "Get
help" link now points here and passes the order/item/status as search
params, which the page uses to show a short, state-specific note and
prefill the message field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:24:51 +02:00
lukasandClaude Sonnet 5 7f3728b5bf Animate basket price and item changes
Reflow remaining basket lines with framer-motion when an item is
removed, slide the per-line price when it changes, and tween the
basket total up/down to its new amount instead of snapping to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:05:24 +02:00
lukasandClaude Sonnet 5 3d736769e0 Wrap stepper button glyphs in a span for iOS Safari centering
Flexbox centering of a bare text node inside a <button> is a known
WebKit quirk — Safari (including iOS) doesn't always vertically center
it the same way it centers an element child. Wrapping the +/- glyph in
its own <span aria-hidden="true"> (the button's own aria-label already
conveys the meaning) is the standard workaround.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 21:26:46 +02:00
lukasandClaude Sonnet 5 b0ef917cfc Center the +/- glyphs in the quantity stepper buttons
.stepperButton relied on the browser's default button content
centering, which the appearance: none reset in the previous commit
took away. Explicitly flex-center it (and zero out padding/line-height)
across all three copies (MenuItemCustomizer, MenuList, basket).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 21:24:23 +02:00
lukasandClaude Sonnet 5 05a91bb5c3 Fix buttons rendering with iOS Safari's default blue chrome
globals.scss's button reset only set font: inherit — no appearance
reset, no color fallback. Several buttons only set a background and
relied on inherited text color, which iOS Safari doesn't honor for
unstyled <button> elements, so their labels/icons rendered in Safari's
native blue instead of the theme color:

- .stepperButton in MenuItemCustomizer, MenuList, and basket
- .refreshButton in admin/kitchen
- .addButton in admin/MenuItemForm (color was only set on :hover)

Added explicit `color` to each, and hardened globals.scss's button
reset with `color: inherit` plus `-webkit-appearance: none` /
`appearance: none` as a fallback for any future button that forgets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 21:22:11 +02:00
lukasandClaude Sonnet 5 0da1f47573 Match the basket's empty-state CTA styling to the orders page
Basket's "Browse the menu" button still had its original plain style
(flat opacity hover, no arrow) from before that button was reworked on
the orders page. Bring it in line: shadow, hover lift, and the shifting
arrow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 20:57:25 +02:00
lukasandClaude Sonnet 5 de158d7c36 Remove the food-icon backdrop, back to a plain empty state
Drop MenuBackdrop entirely and put the icon, title, text, and CTA
directly back on the empty state's own solid background — no separate
overlay card needed once there's no pattern behind it to cover up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 20:54:39 +02:00
lukasandClaude Sonnet 5 04a0e4a708 Zoom the icon pattern in and reduce blur
Scale the background pattern up (background-size 240px -> 340px) and
back off the blur (8px -> 3px) so icons read more clearly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 20:49:45 +02:00
lukasandClaude Sonnet 5 5bc57f0200 Remove the diagonal scroll animation and bump blur to 8px
Static now — drop the background-position keyframe animation and its
now-stale comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 20:48:36 +02:00
lukasandClaude Sonnet 5 c539ef84cd Rework the icon backdrop into a seamlessly scrolling tiled pattern
Replace the per-icon DOM elements (with client-side randomized
placement) with a single small SVG tile rendered once and set as a
repeating CSS background-image. Icons sit in a 4x4 grid within the
tile, evenly spaced and the same size, cycling through the food-icon
shapes and color palette.

Animates via background-position instead of per-element transforms:
moving by exactly one tile size per loop (linear, infinite) scrolls
the whole pattern diagonally forever with a perfectly seamless wrap,
which per-icon placements couldn't do without quadruplicating the DOM.
Also tried a grayscale pass (filter: grayscale(1)) alongside the blur,
now bumped from 2px to 4px.

This also sidesteps the hydration-mismatch concern entirely — there's
no more client-only randomization step, so no server/client mismatch
risk to guard against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
2026-09-13 20:47:21 +02:00
lukasandClaude Sonnet 5 aa8d40bc4e Fix icon colors being overridden, and expand the palette
The .empty svg rule (styling the OrdersIcon/BasketIcon) was a plain
descendant selector, so it also matched — and, with higher specificity
than inherited color, overrode — the backdrop's own food-icon svgs
nested inside the same .empty box. Scope it to a direct child (> svg)
so it only targets the actual empty-state icon.

Also expands the backdrop palette from 3 to 6 colors (adds basil,
berry, and cocoa) for more variety across the icons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:35:00 +02:00
lukasandClaude Sonnet 5 6a81b305bc Give each backdrop icon a distinct light color
Cycle a small warm palette (terracotta, golden-brown, golden-yellow)
across the icons via a CSS custom property instead of one flat tint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:31:43 +02:00
lukasandClaude Sonnet 5 6bd63b53c6 Soften the empty-state icon backdrop with a slight blur
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:29:12 +02:00
lukasandClaude Sonnet 5 8653418778 Match the empty orders icon style to the empty basket state
Drop the tinted circle badge around the icon and use the same plain,
muted-gray sizing as basket.module.scss's empty state instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:26:27 +02:00
lukasandClaude Sonnet 5 2f37b6bba1 Move the food-icon backdrop onto the empty basket/orders cards and animate it
Confine MenuBackdrop to whatever card renders it instead of the whole
viewport: it's now position: absolute inset:0 within its container
(previously position: fixed, spanning the page) with a gentle floating
animation per icon (staggered delay, respects prefers-reduced-motion).
Wired it into the empty basket and empty orders states instead of the
menu list page.

Also gives .empty on both pages position: relative + z-index: 0 so it
forms its own stacking context — without an explicit z-index, the
backdrop's z-index: -1 child was escaping to the document root and
painting either fully behind everything or (on the basket page, where
motion.div meant `.empty` never picked up a context on its own) above
the card's own text, depending on the surrounding markup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:21:10 +02:00
lukasandClaude Sonnet 5 315491dd5b Add a scattered food-icon backdrop to the menu page and tastier copy
MenuBackdrop renders pizza, burger, and fries stroke icons at fixed,
hand-placed positions (no Math.random(), to avoid an SSR/client
hydration mismatch) as a fixed, pointer-events-none layer behind the
page content. Also tweaks the intro line to call out "your tasty menu".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:11:18 +02:00
lukasandClaude Sonnet 5 c5ed038bca Animate the empty orders state in and make its CTA more inviting
Fades/slides the empty state in on mount, gives the icon a tinted
circle badge instead of a bare stroke icon, and gives the "Browse the
menu" button a shadow, hover lift, and a shifting arrow instead of a
flat opacity hover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:07:39 +02:00
lukasandClaude Sonnet 5 f4f3c26e1e Expose the dev server on the local network
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:04:11 +02:00
lukasandClaude Sonnet 5 5f375a0966 Animate orders out with a CSS transition instead of the View Transitions API
The prior attempt wrapped the sign-out router.invalidate() in
document.startViewTransition(), but that had two problems: invalidate()
does its own network refetch inside the transition's update callback,
which freezes the page for the round-trip instead of animating
anything, and TanStack Router's invalidate() explicitly opts itself out
of view transitions (sets shouldViewTransition = false) for exactly
that reason. On top of that, OrdersRoute briefly returns null between
the account loader clearing and the guest-orders lookup resolving,
unmounting OrdersListView and wiping any exit-animation state anyway.

Replace it with a plain, self-contained approach:
- OrdersRoute now keeps rendering the last known non-null orders list
  through that brief gap (a lastKnownOrders ref) instead of unmounting
  OrdersListView, so its local animation state survives.
- OrdersListView holds onto the previous non-empty list for
  EXIT_DURATION_MS after it goes empty, applying an `orderExiting`
  class that fades/slides each card out via a plain CSS transition,
  then swaps to the actual empty state once the transition finishes.

This has no dependency on browser view-transition support, network
timing, or router internals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:03:33 +02:00
lukasandClaude Sonnet 5 8d7f99848c Animate the Orders list out on sign out via the View Transitions API
Wraps the sign-out-triggered router.invalidate() in
document.startViewTransition() so the cleared order cards cross-fade
out (and the empty state fades in) instead of popping away instantly.
Each order card already carries its own view-transition-name (see
OrdersListView), so this reuses that per-card exit; the existing
::view-transition-group(*) timing in globals.scss applies automatically.

Falls back to a plain invalidate() where the API is unsupported, and
swallows rejection on the transition's ready/finished promises since
the browser can abort it in some states (e.g. a backgrounded tab) —
router.invalidate() still runs regardless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:55:14 +02:00
lukasandClaude Sonnet 5 f819b0cd46 Invalidate the router on sign out so account orders clear immediately
Signing out only clears the auth session cookie; a route already
loaded (e.g. /orders) keeps its stale loader data until something
triggers a reload. Call router.invalidate() once signOut succeeds so
the current route's loader reruns against the now-signed-out session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:43:58 +02:00
lukasandClaude Sonnet 5 a2e97c293d Clear guest order refs and basket on sign out
Guest order refs and the basket both live in localStorage independent
of the auth session, so they'd otherwise linger for the next person to
use the browser. Add clearGuestOrderRefs (mirroring clearGuestIdentity)
and call it alongside the existing basket clear() from the sign-out
handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:39:10 +02:00
lukasandClaude Sonnet 5 86d1a32dd8 Use neutral dark gray instead of brown-tinted dark mode palette
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:39:05 +02:00
lukasandClaude Sonnet 5 370e77f1a6 Add dark mode based on device color-scheme preference
Move the color palette into CSS custom properties (:root in
globals.scss) with a prefers-color-scheme: dark override, and point the
existing $color-* Sass variables at them so every component picks up
dark mode automatically. Replaced the handful of rgba($color, alpha)
call sites with color-mix(), since Sass can't alpha-blend a color that's
now a CSS variable at compile time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:33:31 +02:00
lukasandClaude Sonnet 5 a716080f7d Give the top nav bar a translucent glass effect
Adds a backdrop blur and semi-transparent background/border so page
content shows through the sticky header while scrolling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:27:58 +02:00
lukasandClaude Sonnet 5 439932c168 Fix admin panel mobile layout and inconsistent page padding
The admin layout's .layout used align-items: flex-start, correct for
the desktop row (sidebar + content) but wrong once it stacks to a
column at mobile width — align-items controls the cross axis, which
flips from height to width, so children were shrinking to fit their
own content instead of stretching to the viewport. This is what caused
the sidebar's nav links to render squashed/tall and the menu list to
overflow horizontally. Same root cause on .sidebar's own flex-basis,
which fixed its height instead of width once stacked.

Also stacks the menu item form's option/step rows to a column below
the tablet breakpoint (they didn't fit and forced horizontal scroll),
and removes AdminOrdersView's leftover standalone-page padding/max-width
— it was still wrapping its content in the styling meant for a
top-level page, giving "All orders" visibly more padding than Menu/
Kitchen, which just use the admin layout's own padding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:34:42 +02:00
lukasandClaude Sonnet 5 c8d1721358 Add an admin panel: orders dashboard, menu management, kitchen status
Introduces a role-gated /admin section (sidebar layout, requireAdmin
checked both in route loaders and independently in every admin server
function) with three sections:
- Orders: every order across all customers, and an admin-only order
  detail view nested under /admin so the sidebar persists and admin
  access bypasses the guest-token check.
- Menu: create/edit/delete menu items, their options, and their
  preparation steps; deleting an item with past orders is refused
  (protected by the orderItems FK) instead of crashing.
- Kitchen: read-only live view of every machine's busy/idle status and
  per-tag queue depth, with a manual refresh.

Also:
- Adds a `role` field to the user table (Better Auth additionalFields,
  input:false so it can't be self-assigned) and a db:seed-admin script
  to create/promote an admin account.
- Fixes a real bug found while building this: src/server/auth.ts's
  shared session helpers were plain exported functions using a
  server-only import, which the framework couldn't tree-shake out of
  the client bundle once shared across multiple server/*.ts modules —
  this silently broke hydration app-wide. Fixed with createServerOnlyFn.
- Removes the now-fully-unused @tanstack/devtools-vite plugin.
- Disables view transitions for in-admin navigation so the persistent
  sidebar doesn't cross-fade/shift on every click.
- Gives the "your orders" empty state the same visual treatment as the
  basket page's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:14:28 +02:00
lukasandClaude Sonnet 5 18a7d2030e Fix crashes on unknown menu item / order routes
Both loaders threw a plain Error that the router doesn't treat as a
route-level not-found, so visiting /items/<bad-id> or /orders/<bad-id>
(or a valid order with a missing/wrong ?token) crashed instead of
showing the NotFound page. Menu item lookups now throw notFound()
directly; the order loader catches the server function's error and
maps both "order not found" and "wrong access token" to notFound(),
so a guessed id can't be used to probe which orders exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:21:29 +02:00
lukasandClaude Sonnet 5 927f6ff56e Add a NotFound page and remove the TanStack devtools panel
Configures a notFoundComponent on the root route so unmatched paths get a
branded empty state instead of TanStack Router's generic "Not Found" text,
and drops the always-on devtools panel from the root layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:09:43 +02:00
lukasandClaude Sonnet 5 d66e09f515 Add menu item customization/detail pages and configurable currency formatting
Lets customers pick options on a menu item detail page before adding to the
basket, and makes displayed prices respect a configurable currency/locale
via VITE_CURRENCY/VITE_LOCALE. Also crossfades the basket's empty/filled
states with AnimatePresence in wait mode instead of animating both at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:00:45 +02:00
lukasandClaude Sonnet 5 e98b0d86d2 Animate quantity steppers and make the nav bar sticky
- Add an AnimatedNumber component that slides a quantity stepper's
  count horizontally — left on increment, right on decrement — and
  wire it into both the menu and basket steppers.
- Make the top nav bar position: sticky, with an explicit background
  so scrolled content doesn't show through it.
- Reserve the scrollbar's gutter on every page (scrollable or not) so
  the sticky header doesn't shift width when navigating between short
  and tall pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:30:56 +02:00
lukasandClaude Sonnet 5 79bd64ce8c Add guest/account auth flows, order tracking, and order history UI
- Wire up Better Auth (email/password + Google/Apple SSO) with a
  sign-in/sign-up UI, header account menu, and a basket that follows
  the signed-in-or-guest state.
- Give the order status page a vertical timeline per preparation step
  (with a distinct customer-facing name separate from the machine tag,
  a done/in-progress/queued/pending look, and a sonar-ping highlight on
  the active step), a formatted ETA (relative under 3h, absolute clock
  time beyond that), and Buy again / Get help / Cancel actions.
- Expand Pizza's recipe into 6 steps across a shared prep station and
  the existing oven/assembly stations, with matching schema, seed, and
  domain changes plus updated/added test coverage.
- Add an Orders list (account orders via session, guest orders via a
  locally remembered access-token list) with a shared getOrdersForCustomer
  domain summary, and remember a guest's name/contact after their first
  order so checkout doesn't ask again.
- Improve page transitions with the View Transitions API: a shared
  element morph between an order card and its detail page, a stable
  (non-jumping) header across navigations, and reduced-motion handling.
- Add a Ghost Kitchen wordmark (Space Grotesk, faded "Ghost", ghost
  icon standing in for the "o") and shrink the basket icon to match
  the rest of the header controls.
- Add a run-ghost-kitchen agent skill documenting local setup, the
  dev-server driver, and the gotchas hit along the way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:58:24 +02:00
126 changed files with 16394 additions and 478 deletions
+148
View File
@@ -0,0 +1,148 @@
---
name: run-ghost-kitchen
description: Build, run, and drive Ghost Kitchen (TanStack Start ordering app). Use when asked to start the app, run its tests, seed its database, take a screenshot of its UI, or interact with the running app (menu, basket, sign-in/sign-up).
---
Ghost Kitchen is a TanStack Start web app (Vite dev server on port 3000,
Postgres via Docker, Better Auth). There is no `chromium-cli` binary in
this environment — drive it with the `mcp__claude-in-chrome__*` tools
(load them via `ToolSearch` if deferred; see the `claude-in-chrome`
skill). All paths below are relative to the repo root.
## Prerequisites
Docker (for Postgres) and Node — both already present in this
environment. No OS packages were needed.
## Setup
One-time, per fresh checkout / fresh Postgres volume:
```bash
docker compose up -d # starts Postgres on localhost:5433
cp .env.example .env.local
```
`BETTER_AUTH_SECRET` in `.env.example` ships empty — Better Auth needs a
real value or auth routes fail. Generate one and fill it in:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# paste the output as BETTER_AUTH_SECRET= in .env.local
```
Then apply migrations and seed data:
```bash
npm run db:migrate # this project's Drizzle migrations
npm run auth:migrate # Better Auth's own tables (users, sessions, ...)
npm run db:seed # sample Brand, menu, Machines — SKIP if DB already seeded, see Gotchas
```
Against a genuinely empty database, `auth:migrate` prompts interactively
("Are you sure you want to run these migrations?"). Non-interactively,
answer it with `yes | npm run auth:migrate`.
## Build
No separate build step for local dev (Vite serves TS directly). For a
production bundle: `npm run build`.
## Run (agent path)
Start the dev server in the background and wait for it to actually serve
(don't `sleep` — poll the port):
```bash
npm run dev &
timeout 30 bash -c 'until curl -sf http://localhost:3000 >/dev/null; do sleep 1; done'
```
Stop it with `lsof -ti:3000 -sTCP:LISTEN | xargs -r kill` before
relaunching, or the next run hits `EADDRINUSE`. Check for an
already-running instance first (`lsof -i :3000 -sTCP:LISTEN`) — in this
project's dev container a server is often already up from a prior
session; reuse it instead of starting a duplicate.
Drive it with the `mcp__claude-in-chrome__*` tools:
```
tabs_context_mcp { createIfEmpty: true }
navigate { url: "http://localhost:3000", tabId: <tab> }
computer { action: "screenshot", tabId: <tab> }
```
One representative flow that proves the app works end-to-end (menu ->
basket -> sign-up):
```
# Menu page: bump a quantity stepper
find { query: "increment button for Pizza", tabId: <tab> }
computer { action: "left_click", coordinate: [<x>,<y>], tabId: <tab> }
# Basket icon (top-right) -> /basket shows line items + total
computer { action: "left_click", coordinate: [1338, 29], tabId: <tab> } # icon position at 1400px width
# Sign up (/signup): Name, Email, Password fields -> "Create account"
navigate { url: "http://localhost:3000/signup", tabId: <tab> }
# fill Name/Email/Password via computer left_click + type, then click "Create account"
```
Close tabs you created when done (`tabs_close_mcp`).
## Run (human path)
`npm run dev` -> serves on http://localhost:3000, Ctrl-C to stop. Useless
headless without a browser to point at it.
## Test
```bash
npm test # vitest run — 27 tests, 2 files, ~3s
npm run typecheck
npm run lint
```
---
## Gotchas
- **`.env.local` doesn't exist by default** and isn't created by any
script — `cp .env.example .env.local` is a required manual step, and
`BETTER_AUTH_SECRET` inside it ships blank. Leaving it blank breaks
sign-in/sign-up silently at the Better Auth layer.
- **`npm run db:seed` is not idempotent.** Re-running it against an
already-seeded DB throws `DrizzleQueryError: duplicate key value
violates unique constraint "machine_tags_name_idx"` and exits 1. If
Postgres already has data (e.g. a long-lived dev container), skip
seeding rather than treating the error as a setup failure.
- **Port 3000 is often already occupied** by a dev server started
outside the current session (e.g. the user's own terminal). Check
`lsof -i :3000 -sTCP:LISTEN` before launching — if something already
answers `curl -sf http://localhost:3000`, drive that instead of
fighting over the port.
- **Session/basket state persists across browser tab reloads** (basket
contents and signed-in user both survived a fresh tab + navigate in
testing) — a new tab hitting `localhost:3000` may already show a
logged-in user or a non-empty basket from a prior run in the same
container. Not a bug; just don't assume a fresh tab means fresh state.
- **`[TanStack Devtools] Failed to load: TypeError: Failed to fetch
dynamically imported module ...`** shows up in the console on first
load. It's a stale Vite dep-cache issue with the devtools panel, not
an app error — harmless, ignore it (verified via
`read_console_messages`, the rest of the app functions normally).
- **Adding a `NOT NULL` column via `db:generate` against a populated
local DB** makes `drizzle-kit generate` prompt interactively for a
default value — awkward to script. Reset the Postgres volume first
(`docker compose down -v && docker compose up -d`, then recreate the
DB, migrate, `yes | npm run auth:migrate`, reseed) so the column is
added to an empty table and no prompt appears.
## Troubleshooting
- **`Error: Port 3000 is already in use` on `npm run dev`**: another
process already holds it — see the port gotcha above. Either reuse it
(`curl` returns 200) or `lsof -ti:3000 -sTCP:LISTEN | xargs -r kill`
first if you own that process.
- **`duplicate key value violates unique constraint` from `db:seed`**:
DB already seeded — skip the seed step, don't debug it.
+21
View File
@@ -1 +1,22 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=
# ISO 4217 currency code this Kitchen charges and displays prices in, and
# the BCP 47 locale used to format it (decimal separator, digit grouping).
# Default to EUR / de-DE (this deployment's location) when unset.
VITE_CURRENCY=EUR
VITE_LOCALE=de-DE
# Optional: enables "Continue with Google" / "Continue with Apple" on the
# sign-in and sign-up pages. Leave unset to fall back to email/password only.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
# Used by `npm run db:seed-admin` to create/promote one admin account
# (can view every order at /admin/orders). Not read by the app itself.
ADMIN_EMAIL=
ADMIN_PASSWORD=
ADMIN_NAME=
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>
+9 -2
View File
@@ -16,14 +16,14 @@ _Avoid_: Store, location (as a separate multi-tenant concept — this project ha
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens. The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
**Order**: **Order**:
A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up or cancelled. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.) A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up, cancelled, or discarded. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.)
**Charge**: **Charge**:
The single payment transaction covering an Order's full total, created once at placement time. Can be partially refunded multiple times over the Order's life — once per Order Item that gets cancelled — rather than each Order Item carrying its own separate payment. The single payment transaction covering an Order's full total, created once at placement time. Can be partially refunded multiple times over the Order's life — once per Order Item that gets cancelled — rather than each Order Item carrying its own separate payment.
_Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments) _Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments)
**Order Item**: **Order Item**:
A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress). A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress). Separately, the Kitchen itself can cancel an Order Item at any point before it's ready (e.g. a Machine fault), even mid-Step, also with a refund.
_Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state) _Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state)
**Guest Access Token**: **Guest Access Token**:
@@ -37,6 +37,9 @@ The ordered list of Preparation Steps required to prepare a Menu Item. A Menu It
One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion. One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion.
_Avoid_: Stage (use Step consistently) _Avoid_: Stage (use Step consistently)
**Delay**:
Extra time a Machine reports on top of a claimed Preparation Step's estimated duration. Delays add to the Order Item's time to completion; the customer only sees the updated time, never a delay notice.
**Machine**: **Machine**:
A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel. A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel.
@@ -46,5 +49,9 @@ A capability label on a Machine (e.g. "oven", "grill", "assembly") describing wh
**Queue**: **Queue**:
The set of Preparation Steps awaiting a Machine carrying a given Machine Tag, ordered by turn. When such a Machine becomes idle, it takes the next eligible Step from that Tag's Queue. The set of Preparation Steps awaiting a Machine carrying a given Machine Tag, ordered by turn. When such a Machine becomes idle, it takes the next eligible Step from that Tag's Queue.
**Pickup Box**:
A numbered locker at a Kitchen that a ready Order Item is placed in for the customer to collect. Each ready Order Item gets the lowest-numbered free box; if none is free, it waits for the next one to free up. The customer opens the box from their Order and closes it again after taking the item out — closing the box is what marks the Order Item picked up and frees the box. The customer has 15 minutes from when the item goes into the box (the pickup window); after that the item is discarded and the box freed, with no refund. A box that is open when the window runs out isn't emptied, since its customer is taking the item out.
_Avoid_: Counter, shelf (the box is a lockable compartment the customer operates themselves)
**Operator**: **Operator**:
The human role attached to a Kitchen, responsible only for physically restocking ingredients and repairing machines. Never involved in cooking or fulfilling orders. The human role attached to a Kitchen, responsible only for physically restocking ingredients and repairing machines. Never involved in cooking or fulfilling orders.
+36 -3
View File
@@ -69,14 +69,47 @@ npm run db:migrate
## Setting up Better Auth ## Setting up Better Auth
Generate and set the `BETTER_AUTH_SECRET` environment variable in your Generate and set the `BETTER_AUTH_SECRET` environment variable in your
`.env.local`: `.env.local`, and make sure `BETTER_AUTH_URL` matches the URL the app is
actually served from (`http://localhost:3000` in local dev):
```bash ```bash
npx @better-auth/cli secret npx @better-auth/cli secret
``` ```
See the [Better Auth documentation](https://www.better-auth.com) for more on Account creation and sign-in live at `/signup` and `/login`, supporting both
configuring authentication. email/password and "Continue with Google" / "Continue with Apple" SSO. The
SSO buttons only appear once the corresponding provider's credentials are
set in `.env.local`:
```bash
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
APPLE_CLIENT_ID=...
APPLE_CLIENT_SECRET=...
```
Create OAuth credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
and the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list/serviceId)
respectively, with the redirect URI `{BETTER_AUTH_URL}/api/auth/callback/google`
(swap `google` for `apple`). See the
[Better Auth documentation](https://www.better-auth.com) for more on
configuring authentication, and its
[Google](https://www.better-auth.com/docs/authentication/google) and
[Apple](https://www.better-auth.com/docs/authentication/apple) provider
guides for provider-specific setup details.
For Google, `scripts/setup-google-sso.sh` walks you through the Google Cloud
Console steps interactively and writes the resulting `GOOGLE_CLIENT_ID` /
`GOOGLE_CLIENT_SECRET` into `.env` for you:
```bash
./scripts/setup-google-sso.sh
```
Apple's Sign in with Apple doesn't support `localhost` redirect URIs — you
need a real HTTPS domain (a deployment or a tunnel like ngrok) registered on
the Services ID before it's testable, so there's no equivalent script for it
yet.
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "menu_item_steps" ADD COLUMN "name" text NOT NULL;--> statement-breakpoint
ALTER TABLE "order_item_steps" ADD COLUMN "name" text NOT NULL;
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "menu_item_options" (
"id" serial PRIMARY KEY NOT NULL,
"menu_item_id" integer NOT NULL,
"name" text NOT NULL,
"price_delta_cents" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "order_item_options" (
"id" serial PRIMARY KEY NOT NULL,
"order_item_id" integer NOT NULL,
"name" text NOT NULL,
"price_delta_cents" integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE "menu_item_options" ADD CONSTRAINT "menu_item_options_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "order_item_options" ADD CONSTRAINT "order_item_options_order_item_id_order_items_id_fk" FOREIGN KEY ("order_item_id") REFERENCES "public"."order_items"("id") ON DELETE no action ON UPDATE no action;
+7
View File
@@ -0,0 +1,7 @@
CREATE TABLE "contact_messages" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"message" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "allergens" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "menu_item_allergens" (
"id" serial PRIMARY KEY NOT NULL,
"menu_item_id" integer NOT NULL,
"allergen_id" integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_allergen_id_allergens_id_fk" FOREIGN KEY ("allergen_id") REFERENCES "public"."allergens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "allergens_name_idx" ON "allergens" USING btree ("name");--> statement-breakpoint
CREATE UNIQUE INDEX "menu_item_allergens_item_allergen_idx" ON "menu_item_allergens" USING btree ("menu_item_id","allergen_id");
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "order_items" ADD COLUMN "cancelled_by" text;--> statement-breakpoint
ALTER TABLE "preparation_step_instances" ADD COLUMN "delay_seconds" integer DEFAULT 0 NOT NULL;
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE "pickup_boxes" (
"id" serial PRIMARY KEY NOT NULL,
"number" integer NOT NULL,
"is_open" boolean DEFAULT false NOT NULL,
CONSTRAINT "pickup_boxes_number_unique" UNIQUE("number")
);
--> statement-breakpoint
ALTER TABLE "order_items" ADD COLUMN "pickup_box_id" integer;--> statement-breakpoint
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_pickup_box_id_pickup_boxes_id_fk" FOREIGN KEY ("pickup_box_id") REFERENCES "public"."pickup_boxes"("id") ON DELETE no action ON UPDATE no action;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "order_items" ADD COLUMN "picked_up_at" timestamp;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "order_items" ADD COLUMN "placed_in_box_at" timestamp;
+602
View File
@@ -0,0 +1,602 @@
{
"id": "3761e215-1f2d-4374-83af-942ef92e41df",
"prevId": "aecfb37f-fb88-44ba-8e02-f290a3929293",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+705
View File
@@ -0,0 +1,705 @@
{
"id": "cd474707-7446-42df-afd9-44d66a9a03a2",
"prevId": "3761e215-1f2d-4374-83af-942ef92e41df",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+749
View File
@@ -0,0 +1,749 @@
{
"id": "83dc14cf-82fb-43fc-8b7d-fd377bf48689",
"prevId": "cd474707-7446-42df-afd9-44d66a9a03a2",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+870
View File
@@ -0,0 +1,870 @@
{
"id": "4086d6cd-5c93-4617-a15b-a90331b459a1",
"prevId": "83dc14cf-82fb-43fc-8b7d-fd377bf48689",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allergens": {
"name": "allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"allergens_name_idx": {
"name": "allergens_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_allergens": {
"name": "menu_item_allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"allergen_id": {
"name": "allergen_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"menu_item_allergens_item_allergen_idx": {
"name": "menu_item_allergens_item_allergen_idx",
"columns": [
{
"expression": "menu_item_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "allergen_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_allergens_allergen_id_allergens_id_fk": {
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "allergens",
"columnsFrom": [
"allergen_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+883
View File
@@ -0,0 +1,883 @@
{
"id": "1cec3157-9481-4f6a-83b0-a79064d2bed5",
"prevId": "4086d6cd-5c93-4617-a15b-a90331b459a1",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allergens": {
"name": "allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"allergens_name_idx": {
"name": "allergens_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_allergens": {
"name": "menu_item_allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"allergen_id": {
"name": "allergen_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"menu_item_allergens_item_allergen_idx": {
"name": "menu_item_allergens_item_allergen_idx",
"columns": [
{
"expression": "menu_item_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "allergen_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_allergens_allergen_id_allergens_id_fk": {
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "allergens",
"columnsFrom": [
"allergen_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"cancelled_by": {
"name": "cancelled_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delay_seconds": {
"name": "delay_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+942
View File
@@ -0,0 +1,942 @@
{
"id": "42d1f3c1-18f8-42a3-ac41-9e29a4b14add",
"prevId": "1cec3157-9481-4f6a-83b0-a79064d2bed5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allergens": {
"name": "allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"allergens_name_idx": {
"name": "allergens_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_allergens": {
"name": "menu_item_allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"allergen_id": {
"name": "allergen_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"menu_item_allergens_item_allergen_idx": {
"name": "menu_item_allergens_item_allergen_idx",
"columns": [
{
"expression": "menu_item_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "allergen_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_allergens_allergen_id_allergens_id_fk": {
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "allergens",
"columnsFrom": [
"allergen_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"cancelled_by": {
"name": "cancelled_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pickup_box_id": {
"name": "pickup_box_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_pickup_box_id_pickup_boxes_id_fk": {
"name": "order_items_pickup_box_id_pickup_boxes_id_fk",
"tableFrom": "order_items",
"tableTo": "pickup_boxes",
"columnsFrom": [
"pickup_box_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pickup_boxes": {
"name": "pickup_boxes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"is_open": {
"name": "is_open",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pickup_boxes_number_unique": {
"name": "pickup_boxes_number_unique",
"nullsNotDistinct": false,
"columns": [
"number"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delay_seconds": {
"name": "delay_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+948
View File
@@ -0,0 +1,948 @@
{
"id": "4826f90c-c779-4b2d-ab6e-491e72dbaee8",
"prevId": "42d1f3c1-18f8-42a3-ac41-9e29a4b14add",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allergens": {
"name": "allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"allergens_name_idx": {
"name": "allergens_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_allergens": {
"name": "menu_item_allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"allergen_id": {
"name": "allergen_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"menu_item_allergens_item_allergen_idx": {
"name": "menu_item_allergens_item_allergen_idx",
"columns": [
{
"expression": "menu_item_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "allergen_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_allergens_allergen_id_allergens_id_fk": {
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "allergens",
"columnsFrom": [
"allergen_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"cancelled_by": {
"name": "cancelled_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pickup_box_id": {
"name": "pickup_box_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"picked_up_at": {
"name": "picked_up_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_pickup_box_id_pickup_boxes_id_fk": {
"name": "order_items_pickup_box_id_pickup_boxes_id_fk",
"tableFrom": "order_items",
"tableTo": "pickup_boxes",
"columnsFrom": [
"pickup_box_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pickup_boxes": {
"name": "pickup_boxes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"is_open": {
"name": "is_open",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pickup_boxes_number_unique": {
"name": "pickup_boxes_number_unique",
"nullsNotDistinct": false,
"columns": [
"number"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delay_seconds": {
"name": "delay_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+954
View File
@@ -0,0 +1,954 @@
{
"id": "79d9d9d2-6e88-41ed-8d15-160c3549c881",
"prevId": "4826f90c-c779-4b2d-ab6e-491e72dbaee8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allergens": {
"name": "allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"allergens_name_idx": {
"name": "allergens_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_allergens": {
"name": "menu_item_allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"allergen_id": {
"name": "allergen_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"menu_item_allergens_item_allergen_idx": {
"name": "menu_item_allergens_item_allergen_idx",
"columns": [
{
"expression": "menu_item_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "allergen_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_allergens_allergen_id_allergens_id_fk": {
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "allergens",
"columnsFrom": [
"allergen_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"cancelled_by": {
"name": "cancelled_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pickup_box_id": {
"name": "pickup_box_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"placed_in_box_at": {
"name": "placed_in_box_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"picked_up_at": {
"name": "picked_up_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_pickup_box_id_pickup_boxes_id_fk": {
"name": "order_items_pickup_box_id_pickup_boxes_id_fk",
"tableFrom": "order_items",
"tableTo": "pickup_boxes",
"columnsFrom": [
"pickup_box_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pickup_boxes": {
"name": "pickup_boxes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"is_open": {
"name": "is_open",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pickup_boxes_number_unique": {
"name": "pickup_boxes_number_unique",
"nullsNotDistinct": false,
"columns": [
"number"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delay_seconds": {
"name": "delay_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+56
View File
@@ -22,6 +22,62 @@
"when": 1788976393301, "when": 1788976393301,
"tag": "0002_large_lake", "tag": "0002_large_lake",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1789028563859,
"tag": "0003_soft_psynapse",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1789036823859,
"tag": "0004_misty_hedge_knight",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1789636006749,
"tag": "0005_tan_power_man",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1789637974330,
"tag": "0006_worried_shooting_star",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1790804884144,
"tag": "0007_dazzling_norman_osborn",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1790845995013,
"tag": "0008_absurd_the_anarchist",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1790888552762,
"tag": "0009_damp_ender_wiggin",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1790889640651,
"tag": "0010_lush_black_bolt",
"breakpoints": true
} }
] ]
} }
+40 -3
View File
@@ -13,6 +13,7 @@
"better-auth": "^1.5.3", "better-auth": "^1.5.3",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^13.2.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -7934,6 +7935,29 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/framer-motion": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.2.0.tgz",
"integrity": "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^13.2.0",
"motion-utils": "^13.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -8962,6 +8986,21 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/motion-dom": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.2.0.tgz",
"integrity": "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^13.0.0"
}
},
"node_modules/motion-utils": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz",
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10610,9 +10649,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.23.13", "version": "4.23.13",
+3 -1
View File
@@ -6,7 +6,7 @@
"#/*": "./src/*" "#/*": "./src/*"
}, },
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000 --host",
"generate-routes": "tsr generate", "generate-routes": "tsr generate",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
@@ -16,6 +16,7 @@
"db:pull": "drizzle-kit pull", "db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts", "db:seed": "tsx src/db/seed.ts",
"db:seed-admin": "tsx src/db/seed-admin.ts",
"auth:migrate": "better-auth migrate", "auth:migrate": "better-auth migrate",
"lint": "eslint", "lint": "eslint",
"format": "prettier --write . && eslint --fix", "format": "prettier --write . && eslint --fix",
@@ -32,6 +33,7 @@
"better-auth": "^1.5.3", "better-auth": "^1.5.3",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"framer-motion": "^13.2.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
#
# A wizard walks a human through a manual procedure, step by step.
# Generated by the /wizard skill.
#
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
# it. Author the per-step stages below the marker.
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# Wizard library: delightful, consistent UX, identical across every wizard.
# ──────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
else
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
fi
# Author sets this at the top of the stages section.
TOTAL_STAGES=0
_STAGE_INDEX=0
ENV_FILE="${ENV_FILE:-.env}"
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
WRITTEN_SECRET=() # secret NAMEs set this run
SKIPPED=() # things we couldn't do (e.g. gh missing)
# _clear wipes the terminal so only the current step is on screen. No-op when
# output isn't a terminal, so piped logs stay readable.
_clear() {
[[ -t 1 ]] || return 0
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
}
# banner "Title" shows the opening frame: what this wizard does.
banner() {
_clear
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET"
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
printf ' later, since it remembers values already saved.%s\n' "$RESET"
pause "Ready to start?"
}
# stage "Name" clears the screen, then announces a stage and shows progress.
# Clearing keeps only the current step on screen.
stage() {
_clear
_STAGE_INDEX=$((_STAGE_INDEX + 1))
printf '\n%s%s▸ Stage %s/%s · %s%s\n' \
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET"
}
# say "..." prints a plain instruction line.
say() { printf ' %s\n' "$1"; }
# step "..." is a numbered-feeling action the human takes in the browser.
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
# open_url URL opens it in the human's browser, cross-platform incl. WSL.
open_url() {
local url="$1"
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
elif command -v open >/dev/null 2>&1; then open "$url"
else warn "couldn't open a browser; visit it manually: $url"; fi
} >/dev/null 2>&1 || warn "couldn't open a browser, so visit it manually: $url"
}
# pause "msg" waits for the human to confirm they've done the manual part.
pause() {
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
read -r _ || true
}
# confirm "question" is a y/N gate; returns success on yes.
confirm() {
local reply=""
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
read -r reply || true
[[ "$reply" =~ ^[Yy] ]]
}
# _existing KEY: current value of KEY in ENV_FILE, if any.
_existing() {
[[ -f "$ENV_FILE" ]] || return 1
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
printf '%s' "${line#*=}"
}
# ask KEY "Prompt" reads a value into $KEY. Offers the existing .env value as
# a default on re-runs (Enter keeps it). Visible input (non-secret).
ask() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -r input || true
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# ask_secret KEY "Prompt" is like ask, but input is hidden.
ask_secret() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -rs input || true
printf '\n'
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# write_env KEY VALUE upserts KEY=VALUE into ENV_FILE (creates it; replaces
# any existing line). Idempotent.
write_env() {
local key="$1" value="$2" tmp
touch "$ENV_FILE"
tmp=$(mktemp)
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
printf '%s=%s\n' "$key" "$value" >> "$tmp"
mv "$tmp" "$ENV_FILE"
WRITTEN_ENV+=("$key")
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
}
# set_secret NAME VALUE sets a GitHub Actions repo secret via gh. Falls back
# to a warning (and records it) if gh is unavailable or unauthenticated.
set_secret() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
WRITTEN_SECRET+=("$name")
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
warn "skipped GitHub secret $name: gh not ready; set it later"
}
# set_var NAME VALUE sets a GitHub Actions repo variable (non-secret).
set_var() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub variable $name")
warn "skipped GitHub variable $name, gh not ready; set it later"
}
# finish clears, then shows a closing summary of everything configured.
finish() {
_clear
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
if (( ${#SKIPPED[@]} )); then
printf '\n'; warn "still to do by hand:"
for s in "${SKIPPED[@]}"; do note " - $s"; done
fi
printf '\n'
}
# ──────────────────────────────────────────────────────────────────────────
# STAGES: author this section. One stage() per step the human takes.
# Replace the example below. Set TOTAL_STAGES to match the stages you write.
# ──────────────────────────────────────────────────────────────────────────
TOTAL_STAGES=2
banner "Google Sign-In setup"
BETTER_AUTH_URL_VALUE=$(_existing BETTER_AUTH_URL || true)
BETTER_AUTH_URL_VALUE="${BETTER_AUTH_URL_VALUE:-http://localhost:3000}"
REDIRECT_URI="${BETTER_AUTH_URL_VALUE}/api/auth/callback/google"
# ── Stage 1: OAuth consent screen ──────────────────────────────────────────
stage "Google Cloud: OAuth consent screen"
say "Google won't let you create an OAuth client until a consent screen exists"
say "for the project. Skip straight through if yours already has one."
open_url "https://console.cloud.google.com/apis/credentials/consent"
step "Select or create a Google Cloud project for this app."
step "User type: 'External' (or 'Internal' if you're on a Workspace org and"
step " only your org's users will ever sign in)."
step "Fill in App name, User support email, and Developer contact email,"
step " then click through Save on the remaining screens (scopes, test users)."
pause "Done configuring the consent screen?"
# ── Stage 2: OAuth client ID ────────────────────────────────────────────────
stage "Google Cloud: create OAuth client ID"
open_url "https://console.cloud.google.com/apis/credentials"
step "Click '+ Create Credentials' → 'OAuth client ID'."
step "Application type: 'Web application'."
say "Authorized redirect URI to add:"
note " $REDIRECT_URI"
warn "If BETTER_AUTH_URL changes later (e.g. you deploy this), add that"
warn "domain's /api/auth/callback/google URI here too."
step "Paste the redirect URI above into 'Authorized redirect URIs', then click 'Create'."
step "A dialog shows the Client ID and Client secret — copy both."
ask GOOGLE_CLIENT_ID "Paste the Client ID:"
ask_secret GOOGLE_CLIENT_SECRET "Paste the Client secret:"
write_env GOOGLE_CLIENT_ID "$GOOGLE_CLIENT_ID"
write_env GOOGLE_CLIENT_SECRET "$GOOGLE_CLIENT_SECRET"
# ──────────────────────────────────────────────────────────────────────────
finish
note "Restart 'npm run dev' so the server picks up the new env vars."
note "Apple SSO needs a real HTTPS domain (no localhost) — re-run /wizard for"
note "that once you have one to test against."
@@ -0,0 +1,129 @@
@use '../../styles/variables' as *;
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
margin-bottom: $space-4;
}
.heading {
margin: 0;
}
.addLink {
padding: $space-2 $space-4;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.empty {
color: $color-text-muted;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.itemInfo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.itemHeader {
display: flex;
align-items: baseline;
gap: $space-2;
}
.itemName {
font-weight: 600;
}
.itemPrice {
color: $color-text-muted;
font-size: 0.9rem;
}
.itemMeta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemActions {
display: flex;
align-items: center;
gap: $space-2;
flex: 0 0 auto;
}
.editLink {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
color: $color-text;
text-decoration: none;
&:hover {
border-color: $color-primary;
}
}
.deleteButton {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-danger;
cursor: pointer;
&:hover {
border-color: $color-danger;
}
}
.confirmButton {
padding: $space-1 $space-3;
border: none;
border-radius: $radius;
background: $color-danger;
color: $color-primary-contrast;
cursor: pointer;
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: $space-2 0 0;
}
+111
View File
@@ -0,0 +1,111 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import type { AdminMenuItem } from '#/domain/menu.ts'
import { formatPrice } from '#/lib/format.ts'
import styles from './AdminMenuListView.module.scss'
type AdminMenuListViewProps = {
items: Array<AdminMenuItem>
onDelete: (menuItemId: number) => Promise<void>
}
export const AdminMenuListView = ({
items,
onDelete,
}: AdminMenuListViewProps) => {
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const confirmDelete = async (menuItemId: number) => {
setDeletingId(menuItemId)
setError(null)
try {
await onDelete(menuItemId)
} catch (deleteError) {
setError(
deleteError instanceof Error
? deleteError.message
: 'Something went wrong.',
)
} finally {
setDeletingId(null)
setConfirmingId(null)
}
}
return (
<section>
<div className={styles.header}>
<h1 className={styles.heading}>Menu</h1>
<Link
to="/admin/menu/new"
className={styles.addLink}
viewTransition={false}
>
Add item
</Link>
</div>
{items.length === 0 ? (
<p className={styles.empty}>No menu items yet.</p>
) : (
<ul className={styles.items}>
{items.map((item) => (
<li key={item.id} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemHeader}>
<span className={styles.itemName}>{item.name}</span>
<span className={styles.itemPrice}>
{formatPrice(item.priceCents)}
</span>
</div>
<p className={styles.itemMeta}>
{item.steps.length} step
{item.steps.length === 1 ? '' : 's'} ·{' '}
{item.options.length} option
{item.options.length === 1 ? '' : 's'}
{item.allergens.length > 0
? ` · Contains: ${item.allergens.map((allergen) => allergen.name).join(', ')}`
: ''}
{item.description ? ` · ${item.description}` : ''}
</p>
</div>
<div className={styles.itemActions}>
<Link
to="/admin/menu/$menuItemId"
params={{ menuItemId: String(item.id) }}
className={styles.editLink}
viewTransition={false}
>
Edit
</Link>
{confirmingId === item.id ? (
<button
type="button"
className={styles.confirmButton}
disabled={deletingId === item.id}
onClick={() => void confirmDelete(item.id)}
>
{deletingId === item.id ? 'Deleting…' : 'Confirm?'}
</button>
) : (
<button
type="button"
className={styles.deleteButton}
onClick={() => setConfirmingId(item.id)}
>
Delete
</button>
)}
</div>
</li>
))}
</ul>
)}
{error && <p className={styles.error}>{error}</p>}
</section>
)
}
@@ -0,0 +1,174 @@
@use '../../styles/variables' as *;
.form {
display: flex;
flex-direction: column;
gap: $space-4;
max-width: 640px;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input,
textarea,
select {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
font-family: inherit;
color: $color-text;
}
textarea {
resize: vertical;
min-height: 4.5rem;
}
}
.row {
display: flex;
gap: $space-2;
}
.section {
display: flex;
flex-direction: column;
gap: $space-2;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.sectionTitle {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.repeatingRow {
display: flex;
align-items: flex-start;
gap: $space-2;
.field {
flex: 1;
}
@media (max-width: $breakpoint-tablet) {
flex-direction: column;
align-items: stretch;
gap: $space-1;
padding-bottom: $space-2;
border-bottom: 1px solid $color-border;
}
}
.stepName {
flex: 2;
}
.narrowField {
flex: 1;
}
.removeButton {
flex: 0 0 auto;
width: 2.25rem;
height: 2.25rem;
margin-top: 1.35rem;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-danger;
cursor: pointer;
&:hover {
border-color: $color-danger;
}
@media (max-width: $breakpoint-tablet) {
margin-top: 0;
align-self: flex-end;
}
}
.addButton {
align-self: flex-start;
padding: $space-1 $space-3;
border: 1px dashed $color-border;
border-radius: $radius;
background: $color-background;
color: $color-text;
cursor: pointer;
font-size: 0.85rem;
&:hover {
border-color: $color-primary;
color: $color-primary;
}
}
.allergenList {
display: flex;
flex-wrap: wrap;
gap: $space-3;
}
.allergenOption {
display: flex;
align-items: center;
gap: $space-1;
font-size: 0.9rem;
color: $color-text;
cursor: pointer;
}
.actions {
display: flex;
align-items: center;
gap: $space-3;
}
.submitButton {
padding: $space-2 $space-4;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cancelLink {
color: $color-text-muted;
text-decoration: none;
&:hover {
color: $color-text;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
+302
View File
@@ -0,0 +1,302 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import type {
AdminMenuItem,
AllergenOption,
MenuItemInput,
} from '#/domain/menu.ts'
import type { MachineTagOption } from '#/domain/kitchen.ts'
import styles from './MenuItemForm.module.scss'
type OptionRow = { name: string; priceDelta: string }
type StepRow = { name: string; machineTagId: string; durationSeconds: string }
const toOptionRows = (item?: AdminMenuItem): Array<OptionRow> =>
item?.options.map((option) => ({
name: option.name,
priceDelta: (option.priceDeltaCents / 100).toString(),
})) ?? []
const toStepRows = (item?: AdminMenuItem): Array<StepRow> =>
item?.steps.map((step) => ({
name: step.name,
machineTagId: String(step.machineTagId),
durationSeconds: String(step.estimatedDurationSeconds),
})) ?? []
type MenuItemFormProps = {
machineTags: Array<MachineTagOption>
allergenOptions: Array<AllergenOption>
initialItem?: AdminMenuItem
submitLabel: string
onSubmit: (input: MenuItemInput) => Promise<void>
}
export const MenuItemForm = ({
machineTags,
allergenOptions,
initialItem,
submitLabel,
onSubmit,
}: MenuItemFormProps) => {
const [name, setName] = useState(initialItem?.name ?? '')
const [description, setDescription] = useState(
initialItem?.description ?? '',
)
const [price, setPrice] = useState(
initialItem ? (initialItem.priceCents / 100).toString() : '',
)
const [options, setOptions] = useState<Array<OptionRow>>(
toOptionRows(initialItem),
)
const [steps, setSteps] = useState<Array<StepRow>>(toStepRows(initialItem))
const [allergenIds, setAllergenIds] = useState<Array<number>>(
initialItem?.allergens.map((allergen) => allergen.id) ?? [],
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const updateOption = (index: number, patch: Partial<OptionRow>) =>
setOptions((rows) =>
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
)
const updateStep = (index: number, patch: Partial<StepRow>) =>
setSteps((rows) =>
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
)
const toggleAllergen = (allergenId: number) =>
setAllergenIds((ids) =>
ids.includes(allergenId)
? ids.filter((id) => id !== allergenId)
: [...ids, allergenId],
)
const submit = async (event: FormEvent) => {
event.preventDefault()
setError(null)
if (steps.length === 0) {
setError('Add at least one Preparation Step — otherwise this item can never be cooked.')
return
}
const input: MenuItemInput = {
name,
description: description || undefined,
priceCents: Math.round(Number(price) * 100),
options: options
.filter((option) => option.name.trim() !== '')
.map((option) => ({
name: option.name,
priceDeltaCents: Math.round(Number(option.priceDelta || '0') * 100),
})),
steps: steps.map((step) => ({
name: step.name,
machineTagId: Number(step.machineTagId),
estimatedDurationSeconds: Math.round(Number(step.durationSeconds)),
})),
allergenIds,
}
setSubmitting(true)
try {
await onSubmit(input)
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: 'Something went wrong.',
)
} finally {
setSubmitting(false)
}
}
return (
<form className={styles.form} onSubmit={submit}>
<label className={styles.field}>
Name
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Description
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<label className={styles.field}>
Price
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={(event) => setPrice(event.target.value)}
required
/>
</label>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<p className={styles.sectionTitle}>Options</p>
</div>
{options.map((option, index) => (
<div key={index} className={styles.repeatingRow}>
<label className={styles.field}>
Name
<input
value={option.name}
onChange={(event) =>
updateOption(index, { name: event.target.value })
}
/>
</label>
<label className={`${styles.field} ${styles.narrowField}`}>
Price delta
<input
type="number"
step="0.01"
value={option.priceDelta}
onChange={(event) =>
updateOption(index, { priceDelta: event.target.value })
}
/>
</label>
<button
type="button"
className={styles.removeButton}
aria-label={`Remove option ${index + 1}`}
onClick={() =>
setOptions((rows) => rows.filter((_, i) => i !== index))
}
>
×
</button>
</div>
))}
<button
type="button"
className={styles.addButton}
onClick={() =>
setOptions((rows) => [...rows, { name: '', priceDelta: '0' }])
}
>
Add option
</button>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<p className={styles.sectionTitle}>Preparation steps</p>
</div>
{steps.map((step, index) => (
<div key={index} className={styles.repeatingRow}>
<label className={`${styles.field} ${styles.stepName}`}>
Step name
<input
value={step.name}
onChange={(event) =>
updateStep(index, { name: event.target.value })
}
/>
</label>
<label className={`${styles.field} ${styles.narrowField}`}>
Machine
<select
value={step.machineTagId}
onChange={(event) =>
updateStep(index, { machineTagId: event.target.value })
}
>
<option value="" disabled>
Select…
</option>
{machineTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
</label>
<label className={`${styles.field} ${styles.narrowField}`}>
Duration (s)
<input
type="number"
min="1"
value={step.durationSeconds}
onChange={(event) =>
updateStep(index, { durationSeconds: event.target.value })
}
/>
</label>
<button
type="button"
className={styles.removeButton}
aria-label={`Remove step ${index + 1}`}
onClick={() =>
setSteps((rows) => rows.filter((_, i) => i !== index))
}
>
×
</button>
</div>
))}
<button
type="button"
className={styles.addButton}
onClick={() =>
setSteps((rows) => [
...rows,
{ name: '', machineTagId: '', durationSeconds: '60' },
])
}
>
Add step
</button>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<p className={styles.sectionTitle}>Allergens</p>
</div>
<div className={styles.allergenList}>
{allergenOptions.map((allergen) => (
<label key={allergen.id} className={styles.allergenOption}>
<input
type="checkbox"
checked={allergenIds.includes(allergen.id)}
onChange={() => toggleAllergen(allergen.id)}
/>
{allergen.name}
</label>
))}
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.actions}>
<button type="submit" className={styles.submitButton} disabled={submitting}>
{submitting ? 'Saving…' : submitLabel}
</button>
<Link
to="/admin/menu"
className={styles.cancelLink}
viewTransition={false}
>
Cancel
</Link>
</div>
</form>
)
}
+31
View File
@@ -0,0 +1,31 @@
@use '../../styles/variables' as *;
.page {
max-width: 420px;
margin: 0 auto;
padding: $space-2 $space-3;
}
.card {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.heading {
margin: 0;
}
.footer {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
a {
color: $color-primary;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import styles from './AuthCard.module.scss'
type AuthCardProps = {
title: string
children: ReactNode
footer: ReactNode
}
export const AuthCard = ({ title, children, footer }: AuthCardProps) => (
<main className={styles.page}>
<div className={styles.card}>
<h1 className={styles.heading}>{title}</h1>
{children}
<p className={styles.footer}>{footer}</p>
</div>
</main>
)
@@ -0,0 +1,47 @@
@use '../../styles/variables' as *;
.buttons {
display: flex;
flex-direction: column;
gap: $space-2;
}
.google,
.apple {
display: flex;
align-items: center;
justify-content: center;
gap: $space-2;
padding: $space-2 $space-3;
border-radius: $radius;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
}
.google {
background: $color-surface;
color: $color-text;
border: 1px solid $color-border;
&:hover {
background: $color-border;
}
}
.apple {
background: #000;
color: #fff;
border: 1px solid #000;
&:hover {
background: #1a1a1a;
}
}
.icon {
height: 18px;
width: 18px;
flex-shrink: 0;
}
+75
View File
@@ -0,0 +1,75 @@
import { authClient } from '#/lib/auth-client.ts'
import styles from './SsoButtons.module.scss'
type Provider = 'google' | 'apple'
type SsoButtonsProps = {
onError: (message: string) => void
}
const continueWith = async (
provider: Provider,
onError: (message: string) => void,
) => {
const { error } = await authClient.signIn.social({
provider,
callbackURL: '/',
})
if (error) {
onError(error.message ?? `Could not continue with ${provider}.`)
}
}
const GoogleIcon = () => (
<svg className={styles.icon} viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#4285F4"
d="M17.64 9.2045c0-.6381-.0573-1.2518-.1636-1.8409H9v3.4814h4.8436c-.2086 1.125-.8427 2.0782-1.7959 2.7164v2.2581h2.9087c1.7018-1.5668 2.6836-3.874 2.6836-6.615z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.4673-.806 5.9564-2.1805l-2.9087-2.2581c-.8059.5404-1.8368.8595-3.0477.8595-2.3441 0-4.3273-1.5831-5.0345-3.7104H.9573v2.3318C2.4382 15.9832 5.4818 18 9 18z"
/>
<path
fill="#FBBC05"
d="M3.9655 10.71c-.18-.5404-.2823-1.1177-.2823-1.71s.1023-1.1696.2823-1.71V4.9582H.9573A8.9965 8.9965 0 0 0 0 9c0 1.4523.3477 2.8268.9573 4.0418L3.9655 10.71z"
/>
<path
fill="#EA4335"
d="M9 3.5795c1.3214 0 2.5077.4541 3.4405 1.346l2.5813-2.5814C13.4632.8918 11.426 0 9 0 5.4818 0 2.4382 2.0168.9573 4.9582L3.9655 7.29C4.6727 5.1627 6.6559 3.5795 9 3.5795z"
/>
</svg>
)
const AppleIcon = () => (
<svg
className={styles.icon}
viewBox="0 0 384 512"
aria-hidden="true"
fill="currentColor"
>
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
)
export const SsoButtons = ({ onError }: SsoButtonsProps) => (
<div className={styles.buttons}>
<button
type="button"
className={styles.google}
onClick={() => continueWith('google', onError)}
>
<GoogleIcon />
Continue with Google
</button>
<button
type="button"
className={styles.apple}
onClick={() => continueWith('apple', onError)}
>
<AppleIcon />
Continue with Apple
</button>
</div>
)
@@ -0,0 +1,42 @@
@use '../../styles/variables' as *;
.trigger {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
width: 2.25rem;
border: none;
border-radius: 50%;
background: $color-background;
color: $color-text-muted;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
svg {
height: 1.25rem;
width: 1.25rem;
}
}
.badge {
position: absolute;
top: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
padding: 0 3px;
border-radius: 999px;
background: $color-primary;
color: $color-primary-contrast;
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
}
+69
View File
@@ -0,0 +1,69 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useBasket } from '#/lib/basket.tsx'
import styles from './BasketLink.module.scss'
const MotionLink = motion.create(Link)
export const BasketIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h2l2.4 12.2a2 2 0 0 0 2 1.8h8.4a2 2 0 0 0 2-1.6L22 9H6" />
<circle cx="9" cy="20" r="1.4" />
<circle cx="18" cy="20" r="1.4" />
</svg>
)
// Enter bounces in with a spring; exit uses a quick fixed-duration tween
// instead — a spring's slow settling tail would otherwise leave an
// invisible-but-still-interactive link sitting in the DOM for ~2s after it's
// visually gone.
const enterTransition = { type: 'spring', stiffness: 500, damping: 22 } as const
const exitTransition = { duration: 0.18, ease: 'easeIn' } as const
export const BasketLink = () => {
const { itemCount } = useBasket()
const reduceMotion = useReducedMotion()
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const onBasketPage = pathname === '/basket'
const pop = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1, transition: exitTransition },
exit: { opacity: 0, transition: exitTransition },
}
: {
initial: { opacity: 0, scale: 0.4 },
animate: { opacity: 1, scale: 1, transition: enterTransition },
exit: { opacity: 0, scale: 0.4, transition: exitTransition },
}
return (
<AnimatePresence>
{itemCount > 0 && !onBasketPage && (
<MotionLink
key="basket-link"
to="/basket"
className={styles.trigger}
aria-label={`Basket, ${itemCount} item${itemCount === 1 ? '' : 's'}`}
{...pop}
>
<BasketIcon />
<span className={styles.badge}>{itemCount}</span>
</MotionLink>
)}
</AnimatePresence>
)
}
@@ -31,6 +31,11 @@
color: $color-primary-contrast; color: $color-primary-contrast;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
@@ -43,3 +48,18 @@
font-size: 0.85rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
.forgetButton {
align-self: center;
padding: 0;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.8rem;
text-decoration: underline;
cursor: pointer;
&:hover {
color: $color-text;
}
}
+60 -3
View File
@@ -1,6 +1,13 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { authClient } from '#/lib/auth-client.ts' import { authClient } from '#/lib/auth-client.ts'
import {
clearGuestIdentity,
getGuestIdentity,
saveGuestIdentity,
} from '#/lib/guestIdentity.ts'
import type { GuestIdentity } from '#/lib/guestIdentity.ts'
import { addGuestOrderRef } from '#/lib/guestOrders.ts'
import { placeOrder } from '#/server/orders.ts' import { placeOrder } from '#/server/orders.ts'
import styles from './CheckoutSection.module.scss' import styles from './CheckoutSection.module.scss'
@@ -12,7 +19,7 @@ type Customer =
type PlacedOrder = { id: number; guestAccessToken: string | null } type PlacedOrder = { id: number; guestAccessToken: string | null }
type CheckoutSectionProps = { type CheckoutSectionProps = {
items: Array<{ menuItemId: number }> items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
disabled: boolean disabled: boolean
onPlaced: (order: PlacedOrder) => void onPlaced: (order: PlacedOrder) => void
} }
@@ -25,14 +32,36 @@ export const CheckoutSection = ({
const { data: session, isPending } = authClient.useSession() const { data: session, isPending } = authClient.useSession()
const [guestName, setGuestName] = useState('') const [guestName, setGuestName] = useState('')
const [guestContact, setGuestContact] = useState('') const [guestContact, setGuestContact] = useState('')
const [rememberedGuest, setRememberedGuest] = useState<GuestIdentity | null>(
null,
)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Read after mount, not during initial render, so the server-rendered and
// first client-rendered HTML match (no localStorage on the server) and
// React doesn't flag a hydration mismatch — same reasoning as
// BasketProvider.
useEffect(() => {
setRememberedGuest(getGuestIdentity())
}, [])
const submit = async (customer: Customer) => { const submit = async (customer: Customer) => {
setSubmitting(true) setSubmitting(true)
setError(null) setError(null)
try { try {
const order = await placeOrder({ data: { items, customer } }) const order = await placeOrder({ data: { items, customer } })
if (order.guestAccessToken) {
addGuestOrderRef({
orderId: order.id,
guestAccessToken: order.guestAccessToken,
})
}
if (customer.type === 'guest') {
const identity = { name: customer.name, contact: customer.contact }
saveGuestIdentity(identity)
setRememberedGuest(identity)
}
onPlaced(order) onPlaced(order)
} catch (err) { } catch (err) {
setError( setError(
@@ -56,7 +85,35 @@ export const CheckoutSection = ({
disabled={disabled || submitting} disabled={disabled || submitting}
onClick={() => submit({ type: 'account' })} onClick={() => submit({ type: 'account' })}
> >
{submitting ? 'Placing order…' : `Place order as ${session.user.name}`} {submitting ? 'Placing order…' : 'Place order'}
</button>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
if (rememberedGuest) {
return (
<div className={styles.section}>
<button
type="button"
className={styles.submitButton}
disabled={disabled || submitting}
onClick={() => submit({ type: 'guest', ...rememberedGuest })}
>
{submitting
? 'Placing order…'
: `Place order as ${rememberedGuest.name}`}
</button>
<button
type="button"
className={styles.forgetButton}
onClick={() => {
clearGuestIdentity()
setRememberedGuest(null)
}}
>
Not you?
</button> </button>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
</div> </div>
@@ -0,0 +1,95 @@
@use '../../styles/variables' as *;
.customizer {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.heading {
margin: 0;
font-size: 1rem;
}
.panel {
display: flex;
flex-direction: column;
gap: $space-2;
}
.option {
display: flex;
align-items: center;
gap: $space-2;
font-size: 0.9rem;
cursor: pointer;
span:first-of-type {
flex: 1;
}
}
.delta {
color: $color-text-muted;
font-size: 0.8rem;
}
.stepperRow {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: $space-3;
border-top: 1px solid $color-border;
}
.price {
font-weight: 600;
}
.stepper {
display: flex;
align-items: center;
gap: $space-2;
}
.stepperButton {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
margin-right: 0;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-background;
color: $color-text;
line-height: 1;
cursor: pointer;
transition: border-color 0.15s ease;
.icon {
display: flex;
align-items: center;
justify-content: center;
}
&:hover:not(:disabled) {
border-color: $color-primary;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.count {
min-width: 1.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,102 @@
import { useState } from 'react'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import type { BasketLine } from '#/lib/basket.tsx'
import { formatPrice, formatPriceDelta } from '#/lib/format.ts'
import styles from './MenuItemCustomizer.module.scss'
type MenuItemCustomizerProps = {
item: MenuItemWithRecipe
// Every basket line for this item, including the plain no-options one —
// the stepper below tracks whichever combination is currently checked.
lines: Array<BasketLine>
onAdd: (menuItemId: number, optionIds: Array<number>) => void
onRemove: (menuItemId: number, optionIds: Array<number>) => void
}
const sortedKey = (optionIds: Array<number>) =>
[...optionIds].sort((a, b) => a - b).join(',')
export const MenuItemCustomizer = ({
item,
lines,
onAdd,
onRemove,
}: MenuItemCustomizerProps) => {
const [checkedOptionIds, setCheckedOptionIds] = useState<Array<number>>([])
const toggleOption = (optionId: number) =>
setCheckedOptionIds((prev) =>
prev.includes(optionId)
? prev.filter((id) => id !== optionId)
: [...prev, optionId],
)
// The one stepper on this page always reflects whatever combination of
// options is currently checked — checking different boxes just points it
// at a different (possibly already-existing) basket line.
const currentLine = lines.find(
(line) => sortedKey(line.optionIds) === sortedKey(checkedOptionIds),
)
const quantity = currentLine?.quantity ?? 0
const unitPriceCents =
item.priceCents +
checkedOptionIds.reduce(
(sum, id) =>
sum + (item.options.find((option) => option.id === id)?.priceDeltaCents ?? 0),
0,
)
return (
<div className={styles.customizer}>
<h2 className={styles.heading}>Customize</h2>
<div className={styles.panel}>
{item.options.map((option) => (
<label key={option.id} className={styles.option}>
<input
type="checkbox"
checked={checkedOptionIds.includes(option.id)}
onChange={() => toggleOption(option.id)}
/>
<span>{option.name}</span>
{formatPriceDelta(option.priceDeltaCents) && (
<span className={styles.delta}>
{formatPriceDelta(option.priceDeltaCents)}
</span>
)}
</label>
))}
</div>
<div className={styles.stepperRow}>
<AnimatedNumber
value={unitPriceCents}
format={formatPrice}
className={styles.price}
/>
<div className={styles.stepper}>
<button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id, checkedOptionIds)}
disabled={quantity === 0}
aria-label={`Remove one ${item.name}`}
>
<div aria-hidden="true" className={styles.icon}>−</div>
</button>
<AnimatedNumber value={quantity} className={styles.count} />
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id, checkedOptionIds)}
aria-label={`Add one ${item.name}`}
>
<div aria-hidden="true" className={styles.icon}>+</div>
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,33 @@
@use '../../styles/variables' as *;
.detail {
display: flex;
flex-direction: column;
gap: $space-4;
}
.info {
display: flex;
flex-direction: column;
}
.name {
margin: 0 0 $space-1;
}
.description {
margin: 0 0 $space-1;
color: $color-text-muted;
}
.meta {
margin: 0;
color: $color-text-muted;
font-size: 0.9rem;
}
.allergens {
margin: $space-1 0 0;
color: $color-text-muted;
font-size: 0.85rem;
}
@@ -0,0 +1,60 @@
import { MenuItemCustomizer } from '#/components/ordering/MenuItemCustomizer.tsx'
import { formatDuration } from '#/components/ordering/MenuList.tsx'
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import type { BasketLine } from '#/lib/basket.tsx'
import { formatPrice } from '#/lib/format.ts'
import styles from './MenuItemDetail.module.scss'
type MenuItemDetailProps = {
item: MenuItemWithRecipe
lines: Array<BasketLine>
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
}
export const MenuItemDetail = ({
item,
lines,
onAdd,
onRemove,
}: MenuItemDetailProps) => {
const totalSeconds = item.steps.reduce(
(sum, step) => sum + step.estimatedDurationSeconds,
0,
)
return (
<div className={styles.detail}>
<MenuItemThumbnail
name={item.name}
size="hero"
style={{ viewTransitionName: `menu-item-${item.id}` }}
/>
<div className={styles.info}>
<h1 className={styles.name}>{item.name}</h1>
{item.description && (
<p className={styles.description}>{item.description}</p>
)}
<p className={styles.meta}>
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p>
{item.allergens.length > 0 && (
<p className={styles.allergens}>
Contains:{' '}
{item.allergens.map((allergen) => allergen.name).join(', ')}
</p>
)}
</div>
<MenuItemCustomizer
item={item}
lines={lines}
onAdd={onAdd}
onRemove={onRemove}
/>
</div>
)
}
@@ -0,0 +1,37 @@
@use '../../styles/variables' as *;
.thumbnail {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
border-radius: $radius;
overflow: hidden;
background: $color-background;
color: $color-text-muted;
svg {
height: 1.75rem;
width: 1.75rem;
}
}
.hero {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
border-radius: $radius;
svg {
height: 3rem;
width: 3rem;
}
}
.photo {
width: 100%;
height: 100%;
object-fit: cover;
}
@@ -0,0 +1,57 @@
import type { CSSProperties } from 'react'
import styles from './MenuItemThumbnail.module.scss'
type MenuItemThumbnailProps = {
name: string
size?: 'small' | 'hero'
style?: CSSProperties
}
const UtensilsIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 3v7a2 2 0 0 0 2 2v9" />
<path d="M7 3v4M11 3v4" />
<path d="M17 3c-1.5 0-2.5 2-2.5 5s1 5 1.5 5v9" />
</svg>
)
const photoByKeyword: Array<{ keyword: string; src: string }> = [
{ keyword: 'pizza', src: '/pizza.png' },
{ keyword: 'burger', src: '/burger.png' },
]
// Falls back to a simple icon for any menu item that isn't Pizza or Burger,
// since there's no photo for it yet.
const photoFor = (name: string) => {
const lower = name.toLowerCase()
return photoByKeyword.find((entry) => lower.includes(entry.keyword))?.src
}
export const MenuItemThumbnail = ({
name,
size = 'small',
style,
}: MenuItemThumbnailProps) => {
const src = photoFor(name)
return (
<div
className={`${styles.thumbnail} ${size === 'hero' ? styles.hero : ''}`}
style={style}
aria-hidden="true"
>
{src ? (
<img src={src} alt="" className={styles.photo} />
) : (
<UtensilsIcon />
)}
</div>
)
}
+50 -4
View File
@@ -10,16 +10,42 @@
} }
.item { .item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3; padding: $space-3;
border: 1px solid $color-border; border: 1px solid $color-border;
border-radius: $radius; border-radius: $radius;
background: $color-surface; background: $color-surface;
} }
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
}
.main {
display: flex;
align-items: center;
gap: $space-3;
flex: 1;
min-width: 0;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: none;
&:is(a, button) {
cursor: pointer;
&:hover .name {
color: $color-primary;
}
}
}
.info { .info {
flex: 1; flex: 1;
} }
@@ -27,6 +53,7 @@
.name { .name {
margin: 0 0 $space-1; margin: 0 0 $space-1;
font-size: 1.1rem; font-size: 1.1rem;
transition: color 0.15s ease;
} }
.description { .description {
@@ -41,6 +68,12 @@
color: $color-text-muted; color: $color-text-muted;
} }
.allergens {
margin: $space-1 0 0;
font-size: 0.8rem;
color: $color-text-muted;
}
.quantity { .quantity {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -48,12 +81,25 @@
} }
.stepperButton { .stepperButton {
display: flex;
align-items: center;
justify-content: center;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
padding: 0;
border-radius: 50%; border-radius: 50%;
border: 1px solid $color-border; border: 1px solid $color-border;
background: $color-background; background: $color-background;
color: $color-text;
line-height: 1;
cursor: pointer; cursor: pointer;
align-self: center;
justify-self: center;
transition: border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
}
&:disabled { &:disabled {
opacity: 0.4; opacity: 0.4;
+68 -35
View File
@@ -1,37 +1,43 @@
import { Link } from '@tanstack/react-router'
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts' import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import type { BasketLine } from '#/lib/basket.tsx'
import { formatPrice } from '#/lib/format.ts'
import styles from './MenuList.module.scss' import styles from './MenuList.module.scss'
type MenuListProps = { type MenuListProps = {
menu: Array<MenuItemWithRecipe> menu: Array<MenuItemWithRecipe>
quantities: Record<number, number> lines: Array<BasketLine>
onAdd: (menuItemId: number) => void onAdd: (menuItemId: number, optionIds?: Array<number>) => void
onRemove: (menuItemId: number) => void onRemove: (menuItemId: number, optionIds?: Array<number>) => void
} }
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}` export const formatDuration = (seconds: number) => {
const formatDuration = (seconds: number) => {
const minutes = Math.round(seconds / 60) const minutes = Math.round(seconds / 60)
return minutes <= 1 ? '~1 min' : `~${minutes} min` return minutes <= 1 ? '1 min' : `${minutes} min`
} }
export const MenuList = ({ export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
menu,
quantities,
onAdd,
onRemove,
}: MenuListProps) => (
<ul className={styles.list}> <ul className={styles.list}>
{menu.map((item) => { {menu.map((item) => {
const quantity = quantities[item.id] ?? 0 const defaultLine = lines.find(
(line) => line.menuItemId === item.id && line.optionIds.length === 0,
)
const hasOptions = item.options.length > 0
const totalSeconds = item.steps.reduce( const totalSeconds = item.steps.reduce(
(sum, step) => sum + step.estimatedDurationSeconds, (sum, step) => sum + step.estimatedDurationSeconds,
0, 0,
) )
return ( const info = (
<li key={item.id} className={styles.item}> <>
<MenuItemThumbnail
name={item.name}
style={{ viewTransitionName: `menu-item-${item.id}` }}
/>
<div className={styles.info}> <div className={styles.info}>
<h3 className={styles.name}>{item.name}</h3> <h3 className={styles.name}>{item.name}</h3>
{item.description && ( {item.description && (
@@ -40,26 +46,53 @@ export const MenuList = ({
<p className={styles.meta}> <p className={styles.meta}>
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)} {formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p> </p>
{item.allergens.length > 0 && (
<p className={styles.allergens}>
Contains:{' '}
{item.allergens.map((allergen) => allergen.name).join(', ')}
</p>
)}
</div> </div>
<div className={styles.quantity}> </>
<button )
type="button"
className={styles.stepperButton} return (
onClick={() => onRemove(item.id)} <li key={item.id} className={styles.item}>
disabled={quantity === 0} <div className={styles.row}>
aria-label={`Remove one ${item.name}`} {hasOptions ? (
> <Link
− to="/items/$menuItemId"
</button> params={{ menuItemId: String(item.id) }}
<span className={styles.count}>{quantity}</span> className={styles.main}
<button >
type="button" {info}
className={styles.stepperButton} </Link>
onClick={() => onAdd(item.id)} ) : (
aria-label={`Add one ${item.name}`} <div className={styles.main}>{info}</div>
> )}
+ <div className={styles.quantity}>
</button> <button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id)}
disabled={(defaultLine?.quantity ?? 0) === 0}
aria-label={`Remove one ${item.name}`}
>
<span aria-hidden="true">−</span>
</button>
<AnimatedNumber
value={defaultLine?.quantity ?? 0}
className={styles.count}
/>
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id)}
aria-label={`Add one ${item.name}`}
>
<span aria-hidden="true">+</span>
</button>
</div>
</div> </div>
</li> </li>
) )
@@ -1,35 +0,0 @@
@use '../../styles/variables' as *;
.empty {
color: $color-text-muted;
font-size: 0.9rem;
}
.summary {
display: flex;
flex-direction: column;
gap: $space-2;
}
.lines {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-1;
}
.line {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.total {
display: flex;
justify-content: space-between;
font-weight: 600;
padding-top: $space-2;
border-top: 1px solid $color-border;
}
-46
View File
@@ -1,46 +0,0 @@
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import styles from './OrderSummary.module.scss'
type OrderSummaryProps = {
menu: Array<MenuItemWithRecipe>
quantities: Record<number, number>
}
export const OrderSummary = ({ menu, quantities }: OrderSummaryProps) => {
const lines = menu
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
.filter(({ quantity }) => quantity > 0)
if (lines.length === 0) {
return (
<p className={styles.empty}>
Your order is empty — add something from the menu.
</p>
)
}
const totalCents = lines.reduce(
(sum, { item, quantity }) => sum + item.priceCents * quantity,
0,
)
return (
<div className={styles.summary}>
<ul className={styles.lines}>
{lines.map(({ item, quantity }) => (
<li key={item.id} className={styles.line}>
<span>
{quantity} × {item.name}
</span>
<span>${((item.priceCents * quantity) / 100).toFixed(2)}</span>
</li>
))}
</ul>
<div className={styles.total}>
<span>Total</span>
<span>${(totalCents / 100).toFixed(2)}</span>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
@use '../../styles/variables' as *;
.heading {
margin: 0 0 $space-4;
}
.empty {
color: $color-text-muted;
}
.orders {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.order {
display: block;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
color: inherit;
text-decoration: none;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
}
.orderHeader {
display: flex;
justify-content: space-between;
font-weight: 600;
margin-bottom: $space-1;
}
.orderStatus {
text-transform: capitalize;
color: $color-text-muted;
font-weight: 500;
}
.orderCustomer {
margin: 0 0 $space-1;
font-size: 0.9rem;
color: $color-text-muted;
}
.orderItems {
margin: 0 0 $space-1;
font-size: 0.9rem;
}
.orderMeta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
}
+62
View File
@@ -0,0 +1,62 @@
import { Link } from '@tanstack/react-router'
import type { AdminOrderSummary } from '#/domain/ordering.ts'
import { formatPrice } from '#/lib/format.ts'
import styles from './AdminOrdersView.module.scss'
type AdminOrdersViewProps = {
orders: Array<AdminOrderSummary>
}
const formatItems = (items: AdminOrderSummary['items']) =>
items
.map(({ menuItemName, quantity }) =>
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
)
.join(', ')
const formatCustomer = (customer: AdminOrderSummary['customer']) =>
customer.type === 'account'
? `Account ${customer.customerId}`
: `${customer.name} (guest) · ${customer.contact}`
export const AdminOrdersView = ({ orders }: AdminOrdersViewProps) => (
<section>
<h1 className={styles.heading}>All orders</h1>
{orders.length === 0 ? (
<p className={styles.empty}>No orders have been placed yet.</p>
) : (
<ul className={styles.orders}>
{orders.map((order) => (
<li key={order.id}>
<Link
to="/admin/orders/$orderId"
params={{ orderId: String(order.id) }}
className={styles.order}
viewTransition={false}
>
<div className={styles.orderHeader}>
<span>Order #{order.id}</span>
<span className={styles.orderStatus}>{order.status}</span>
</div>
<p className={styles.orderCustomer}>
{formatCustomer(order.customer)}
</p>
<p className={styles.orderItems}>{formatItems(order.items)}</p>
<p className={styles.orderMeta}>
{order.createdAt.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}{' '}
· {formatPrice(order.totalCents)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
)
@@ -0,0 +1,19 @@
@use '../../styles/variables' as *;
.eta {
margin: 0 0 $space-2;
font-size: 0.9rem;
color: $color-success;
}
.value {
position: relative;
display: inline-flex;
overflow: hidden;
vertical-align: bottom;
}
.valueText {
display: inline-block;
white-space: nowrap;
}
+30
View File
@@ -0,0 +1,30 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { formatEtaParts } from '#/lib/format.ts'
import styles from './EtaMessage.module.scss'
export const EtaMessage = ({ etaSeconds }: { etaSeconds: number }) => {
const reduceMotion = useReducedMotion()
const { prefix, value } = formatEtaParts(etaSeconds)
return (
<p className={styles.eta}>
{prefix}{' '}
<span className={styles.value}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={value}
className={styles.valueText}
initial={reduceMotion ? false : { y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { y: '-100%', opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.35, ease: 'easeOut' }}
>
{value}
</motion.span>
</AnimatePresence>
</span>
</p>
)
}
@@ -0,0 +1,38 @@
@use '../../styles/variables' as *;
.banner {
display: flex;
align-items: center;
gap: $space-2;
color: $color-success;
&[data-variant='cancelled'] {
color: $color-danger;
}
}
.icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.text {
display: flex;
flex-direction: column;
}
.label {
font-size: 0.9rem;
font-weight: 600;
}
.detail {
font-size: 0.8rem;
color: $color-text-muted;
}
@@ -0,0 +1,67 @@
import { motion, useReducedMotion } from 'framer-motion'
import styles from './ItemStatusBanner.module.scss'
export type ItemStatusBannerVariant = 'ready' | 'pickedUp' | 'cancelled'
const iconPaths = {
ready: ['M7.5 12.5l3 3 6-6.5'],
pickedUp: ['M7.5 12.5l3 3 6-6.5'],
cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'],
} satisfies Record<ItemStatusBannerVariant, Array<string>>
type ItemStatusBannerProps = {
variant: ItemStatusBannerVariant
label: string
detail?: string
animateIn: boolean
}
export const ItemStatusBanner = ({
variant,
label,
detail,
animateIn,
}: ItemStatusBannerProps) => {
const reduceMotion = useReducedMotion()
const skipAnimation = !animateIn || reduceMotion
return (
<motion.div
className={styles.banner}
data-variant={variant}
role="status"
initial={skipAnimation ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true">
<motion.circle
cx="12"
cy="12"
r="10"
initial={skipAnimation ? false : { pathLength: 0, rotate: -90 }}
animate={{ pathLength: 1, rotate: -90 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
{iconPaths[variant].map((d, index) => (
<motion.path
key={d}
d={d}
initial={skipAnimation ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: 0.3,
ease: 'easeOut',
delay: 0.45 + index * 0.2,
}}
/>
))}
</svg>
<span className={styles.text}>
<span className={styles.label}>{label}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</span>
</motion.div>
)
}
+331 -18
View File
@@ -6,16 +6,71 @@
padding: $space-5 $space-3; padding: $space-5 $space-3;
} }
.backLink {
display: inline-flex;
align-items: center;
gap: $space-1;
margin: 0 0 $space-3;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: none;
&:hover {
color: $color-primary;
.backArrow {
transform: translateX(-3px);
}
}
}
.backArrow {
height: 1rem;
width: 1rem;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.heading { .heading {
margin: 0 0 $space-1; margin: 0 0 $space-1;
} }
.orderStatus { .orderStatus {
margin: 0 0 $space-4; margin: 0 0 $space-1;
color: $color-text-muted; color: $color-text-muted;
text-transform: capitalize; text-transform: capitalize;
} }
.orderDate {
margin: 0 0 $space-4;
color: $color-text-muted;
font-size: 0.85rem;
}
.simulateButton {
margin: 0 0 $space-4;
padding: $space-1 $space-3;
border: 1px dashed $color-text-muted;
border-radius: $radius;
background: none;
color: $color-text-muted;
font-size: 0.85rem;
cursor: pointer;
transition:
border-color 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
color: $color-primary;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.error { .error {
color: $color-danger; color: $color-danger;
} }
@@ -39,53 +94,311 @@
.itemHeader { .itemHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
font-weight: 600; font-weight: 600;
margin-bottom: $space-2; margin-bottom: $space-2;
} }
.itemStatus { .menuButton {
text-transform: capitalize; display: inline-flex;
flex-shrink: 0;
margin: (-$space-2) (-$space-2) (-$space-2) $space-2;
padding: $space-2;
border: none;
border-radius: $radius;
background: none;
color: $color-text-muted; color: $color-text-muted;
cursor: pointer;
svg {
width: 1.25rem;
height: 1.25rem;
stroke-width: 3;
}
&:hover {
color: $color-text;
}
} }
.steps { .menu {
position: fixed;
position-area: bottom span-left;
position-try-fallbacks: flip-block;
inset: auto;
margin: $space-1 0;
padding: $space-1;
min-width: 160px;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
color: $color-text;
font-weight: 400;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.menuItem {
display: flex;
align-items: center;
gap: $space-2;
width: 100%;
padding: $space-2 $space-3;
border: none;
border-radius: calc(#{$radius} - 2px);
background: none;
color: inherit;
font: inherit;
font-size: 0.9rem;
text-align: left;
text-decoration: none;
cursor: pointer;
&:hover:not(:disabled) {
background: $color-background;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
}
.itemOptions {
margin: 0 0 $space-2; margin: 0 0 $space-2;
padding-left: $space-4;
font-size: 0.85rem; font-size: 0.85rem;
color: $color-text-muted; color: $color-text-muted;
} }
.step { // The negative margin + matching padding leaves room for the in-progress
&[data-status='done'] { // dot's sonar ping, which the collapse animation's overflow would clip.
text-decoration: line-through; .timelineWrapper {
} overflow: hidden;
margin: (-$space-2);
padding: $space-2;
}
&[data-status='in_progress'] { .bannerRow {
color: $color-primary; display: flex;
font-weight: 600; align-items: center;
justify-content: space-between;
gap: $space-2;
margin: 0 0 $space-2;
}
.timeline {
margin: 0 0 $space-2;
padding: 0;
list-style: none;
}
.step {
position: relative;
display: grid;
grid-template-columns: $space-3 1fr;
column-gap: $space-2;
padding-bottom: $space-3;
font-size: 0.85rem;
color: $color-text-muted;
&:last-child {
padding-bottom: 0;
} }
} }
.eta { .stepMarker {
margin: 0 0 $space-2; position: relative;
font-size: 0.9rem; display: flex;
color: $color-success; justify-content: center;
}
.stepDot {
position: relative;
width: 10px;
height: 10px;
margin-top: 3px;
border: 2px solid $color-border;
border-radius: 50%;
background: $color-background;
z-index: 1;
}
.stepConnector {
position: absolute;
top: 15px;
bottom: -$space-3;
left: 50%;
width: 2px;
background: $color-border;
transform: translateX(-50%);
overflow: hidden;
}
.stepConnectorFill {
position: absolute;
inset: 0;
background: $color-success;
transform-origin: top;
}
.step:last-child .stepConnector {
display: none;
}
.stepContent {
display: flex;
flex-direction: column;
gap: $space-1;
padding-top: 1px;
}
.stepMeta {
font-size: 0.75rem;
}
.step[data-status='done'] {
opacity: 0.3;
.stepDot {
border-color: $color-success;
background: $color-success;
}
.stepLabel {
color: $color-text;
}
}
.step[data-status='in_progress'] {
.stepDot {
border-color: $color-success;
background: $color-success;
@media (prefers-reduced-motion: no-preference) {
animation: dotPopIn 0.4s ease-out 0.6s backwards;
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: $color-success;
opacity: 0;
animation: sonarPing 1.6s ease-out 1s infinite;
}
}
}
}
// Animates the box size instead of transform: scale, which Chrome
// rasterizes at a low resolution mid-animation and renders the tiny dot square.
@keyframes dotPopIn {
0% {
border-color: $color-border;
background: $color-background;
}
50% {
width: 14px;
height: 14px;
margin-top: 1px;
}
}
@keyframes sonarPing {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(2.8);
opacity: 0;
}
}
.step[data-status='pending'] {
opacity: 0.6;
} }
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: $space-2; gap: $space-2;
align-items: center;
button { button,
padding: $space-1 $space-3; a {
display: inline-flex;
align-items: center;
gap: $space-1;
padding: $space-2 $space-4;
border: 1px solid $color-border; border: 1px solid $color-border;
border-radius: $radius; border-radius: $radius;
background: $color-background; background: $color-background;
color: $color-text;
text-decoration: none;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
}
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
} }
.pickupButton {
overflow: hidden;
white-space: nowrap;
transition: opacity 0.3s ease;
border-color: $color-text;
background: $color-text;
color: $color-background;
font-weight: 600;
&:hover:not(:disabled) {
border-color: $color-text;
opacity: 0.9;
}
@media (max-width: $breakpoint-tablet) {
flex-basis: 100%;
justify-content: center;
margin-top: $space-2;
margin-bottom: $space-2;
}
}
.actionIcon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
// The shackle lifts above the 24-unit viewBox when unlocked.
.lockIcon {
overflow: visible;
}
.cancelButton {
padding: $space-1 $space-2;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: underline;
&:hover:not(:disabled) {
color: $color-danger;
}
}
} }
+525 -59
View File
@@ -1,32 +1,300 @@
import { useRouter } from '@tanstack/react-router' import { Link, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { HoldButton } from '#/components/ui/HoldButton.tsx'
import type { OrderStatus } from '#/db/schema.ts' import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts' import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts' import { useBasket } from '#/lib/basket.tsx'
import { formatCountdown } from '#/lib/format.ts'
import {
advanceSimulatedKitchen,
cancelOrderItem,
closePickupBox,
getOrderStatus,
openPickupBox,
} from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss' import styles from './OrderStatusView.module.scss'
import { EtaMessage } from './EtaMessage.tsx'
import { ItemStatusBanner } from './ItemStatusBanner.tsx'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const isSettled = (item: OrderItemStatusView) =>
item.status === 'ready' ||
item.status === 'picked_up' ||
item.status === 'cancelled' ||
item.status === 'discarded'
// `animationKey` groups variants that should read as one continuous banner:
// going from ready to picked up swaps the label without replaying the check.
// `now` is null until the page hydrates, so the server and first client
// render agree and the countdown only appears once it can tick.
const getItemBanner = (item: OrderItemStatusView, now: number | null) => {
if (item.status === 'ready') {
return {
animationKey: 'done',
variant: 'ready',
label: 'Ready for pickup',
detail: !item.pickupBox
? 'Waiting for a free pickup box'
: item.pickupDeadline && now !== null
? `Pickup box ${item.pickupBox.number} · ${formatCountdown(item.pickupDeadline.getTime() - now)} left`
: `Pickup box ${item.pickupBox.number}`,
} as const
}
if (item.status === 'discarded') {
return {
animationKey: 'discarded',
variant: 'cancelled',
label: 'Not picked up in time',
detail: `${item.pickupBox ? `Pickup box ${item.pickupBox.number}` : 'The pickup box'} was emptied after 15 minutes. This item can't be refunded.`,
} as const
}
if (item.status === 'picked_up') {
return {
animationKey: 'done',
variant: 'pickedUp',
label: 'Picked up',
detail: item.pickupBox
? `Pickup box ${item.pickupBox.number}`
: undefined,
} as const
}
if (item.status === 'cancelled') {
return item.cancelledBy === 'kitchen'
? ({
animationKey: 'cancelled',
variant: 'cancelled',
label: 'Cancelled by the kitchen',
detail:
"Something went wrong on our end, so we've refunded this item.",
} as const)
: ({
animationKey: 'cancelled',
variant: 'cancelled',
label: 'Cancelled',
detail: "You've been refunded for this item.",
} as const)
}
return null
}
const bannerKey = (item: OrderItemStatusView) => {
const banner = getItemBanner(item, null)
return banner ? `${item.id}:${banner.animationKey}` : null
}
const BackArrowIcon = () => (
<svg
className={styles.backArrow}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 12H5" />
<path d="M11 18l-6-6 6-6" />
</svg>
)
const actionIconPaths = {
buyAgain: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5'],
help: [
'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3',
'M12 17h.01',
],
cancel: ['M18 6 6 18', 'M6 6l12 12'],
more: ['M12 12h.01', 'M19 12h.01', 'M5 12h.01'],
}
const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
<svg
className={styles.actionIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{actionIconPaths[name].map((d) => (
<path key={d} d={d} />
))}
</svg>
)
const CLOSED_BOX_VISIBLE_MS = 5 * 60 * 1000
// Same commands in both so the shackle morphs: unlocked, the left leg
// stretches up from the body and the right leg shrinks to a stub.
const LOCKED_SHACKLE = 'M7 11V7a5 5 0 0 1 10 0v4'
const UNLOCKED_SHACKLE = 'M7 11V4a5 5 0 0 1 10 0v1'
// Overshoots past the target, so the shackle springs open and snaps shut.
const SHACKLE_EASE = [0.34, 1.8, 0.64, 1] as const
const pickupButtonLabel = (
box: { number: number; isOpen: boolean },
closed: boolean,
) => {
if (closed) return `Box ${box.number} closed`
return box.isOpen
? `Hold to close box ${box.number}`
: `Hold to open box ${box.number}`
}
const LockIcon = ({ open }: { open: boolean }) => {
const reduceMotion = useReducedMotion()
return (
<motion.svg
className={`${styles.actionIcon} ${styles.lockIcon}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={{ originY: 1 }}
initial={false}
animate={
open
? {
y: [0, -4, 1, -1.5, 0],
scaleX: [1, 0.94, 1.06, 0.98, 1],
scaleY: [1, 1.1, 0.92, 1.03, 1],
}
: {
y: [0, 2, -3.5, 1, -1, 0],
scaleX: [1, 1.14, 0.94, 1.04, 0.99, 1],
scaleY: [1, 0.8, 1.1, 0.96, 1.02, 1],
}
}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.65, ease: 'easeOut' }
}
>
<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" />
<motion.path
initial={false}
animate={{ d: open ? UNLOCKED_SHACKLE : LOCKED_SHACKLE }}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.4, ease: SHACKLE_EASE }
}
/>
</motion.svg>
)
}
const StepConnector = ({ filled }: { filled: boolean }) => {
const reduceMotion = useReducedMotion()
return (
<span className={styles.stepConnector}>
<motion.span
className={styles.stepConnectorFill}
initial={false}
animate={{ scaleY: filled ? 1 : 0 }}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }
}
/>
</span>
)
}
export type OrderStatusResult = { export type OrderStatusResult = {
id: number id: number
status: OrderStatus status: OrderStatus
createdAt: Date
items: Array<OrderItemStatusView> items: Array<OrderItemStatusView>
} }
type OrderStatusViewProps = { type OrderStatusViewProps = {
status: OrderStatusResult status: OrderStatusResult
guestAccessToken: string | undefined guestAccessToken: string | undefined
backTo?: '/orders' | '/admin/orders'
} }
export const OrderStatusView = ({ export const OrderStatusView = ({
status, status,
guestAccessToken, guestAccessToken,
backTo = '/orders',
}: OrderStatusViewProps) => { }: OrderStatusViewProps) => {
const router = useRouter() const router = useRouter()
const navigate = useNavigate()
const basket = useBasket()
const [pendingItemId, setPendingItemId] = useState<number | null>(null) const [pendingItemId, setPendingItemId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [simulating, setSimulating] = useState(false)
// Only banners that appear while the page is open animate in; ones
// already showing on load render straight away.
const [initialBannerKeys] = useState(
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
)
// Ticks every second while a pickup countdown runs; otherwise only wakes
// when a closed box's button (kept for a while after pickup) is due to go.
const [now, setNow] = useState(() => Date.now())
const [hydrated, setHydrated] = useState(false)
useEffect(() => setHydrated(true), [])
const hasPickupCountdown = status.items.some(
(item) => item.pickupDeadline !== null,
)
useEffect(() => {
if (!hasPickupCountdown) return
setNow(Date.now())
const interval = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(interval)
}, [hasPickupCountdown])
useEffect(() => {
if (hasPickupCountdown) return
const hideAt = status.items
.flatMap((item) =>
item.pickedUpAt
? [item.pickedUpAt.getTime() + CLOSED_BOX_VISIBLE_MS]
: [],
)
.filter((time) => time > now)
if (hideAt.length === 0) return
const timeout = setTimeout(
() => setNow(Date.now()),
Math.min(...hideAt) - Date.now(),
)
return () => clearTimeout(timeout)
}, [status.items, now, hasPickupCountdown])
// Once a countdown runs out, reload so the server can empty the box and the
// item shows as discarded. Only once per item: an open box isn't emptied.
const refreshedExpiredItemIds = useRef(new Set<number>())
useEffect(() => {
const newlyExpired = status.items.filter(
(item) =>
item.pickupDeadline !== null &&
item.pickupDeadline.getTime() <= now &&
!refreshedExpiredItemIds.current.has(item.id),
)
if (newlyExpired.length === 0) return
for (const item of newlyExpired)
refreshedExpiredItemIds.current.add(item.id)
void router.invalidate()
}, [status.items, now, router])
const reduceMotion = useReducedMotion()
const collapseTransition = reduceMotion
? { duration: 0 }
: { duration: 0.4, ease: 'easeInOut' as const }
const runAction = async (orderItemId: number, action: () => Promise<void>) => { const buyAgain = (item: OrderItemStatusView) => {
basket.add(item.menuItemId, item.buyAgainOptionIds)
void navigate({ to: '/basket' })
}
const runAction = async (
orderItemId: number,
action: () => Promise<void>,
) => {
setPendingItemId(orderItemId) setPendingItemId(orderItemId)
setError(null) setError(null)
try { try {
@@ -39,71 +307,269 @@ export const OrderStatusView = ({
} }
} }
// Collapses to nothing on both axes, so the row closes the gap whether the
// button sits inline (desktop) or on its own full-width line (mobile); the
// negative margins cancel the flex gap it leaves behind.
const pickupButtonExit = reduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
width: 0,
height: 0,
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
borderWidth: 0,
marginTop: 0,
marginRight: -8,
marginBottom: -8,
transition: { duration: 0.3, ease: 'easeInOut' as const },
}
const togglePickupBox = (item: OrderItemStatusView) => {
const toggle = item.pickupBox?.isOpen ? closePickupBox : openPickupBox
return toggle({ data: { orderItemId: item.id, guestAccessToken } })
}
const runKitchenSimulation = async () => {
setSimulating(true)
setError(null)
try {
for (;;) {
await advanceSimulatedKitchen()
const fresh = await getOrderStatus({
data: { orderId: status.id, guestAccessToken },
})
await router.invalidate()
if (
!fresh.items.some(
(item) => item.status === 'placed' || item.status === 'paid',
)
) {
return
}
await sleep(1000)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setSimulating(false)
}
}
const hasItemsToSimulate = status.items.some(
(item) => item.status === 'placed' || item.status === 'paid',
)
return ( return (
<section className={styles.page}> <section className={styles.page}>
<h1 className={styles.heading}>Order #{status.id}</h1> <Link
<p className={styles.orderStatus}>Status: {status.status}</p> to={backTo}
className={styles.backLink}
viewTransition={backTo !== '/admin/orders'}
>
<BackArrowIcon />
{backTo === '/admin/orders' ? 'Back to all orders' : 'Back to orders'}
</Link>
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
<h1 className={styles.heading}>Order #{status.id}</h1>
<p className={styles.orderStatus}>Status: {status.status}</p>
<p className={styles.orderDate}>
Placed{' '}
{status.createdAt.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})}
{' at '}
{status.createdAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
{import.meta.env.DEV && hasItemsToSimulate && (
<button
type="button"
className={styles.simulateButton}
disabled={simulating}
onClick={() => void runKitchenSimulation()}
>
{simulating ? 'Simulating kitchen…' : 'Simulate kitchen (dev)'}
</button>
)}
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}> <ul className={styles.items}>
{status.items.map((item) => ( {status.items.map((item) => {
<li key={item.id} className={styles.item}> const banner = getItemBanner(item, hydrated ? now : null)
<div className={styles.itemHeader}> const boxClosed = item.status !== 'ready'
<span className={styles.itemName}>{item.menuItemName}</span> const pickupBox =
<span className={styles.itemStatus}>{item.status}</span> item.status === 'ready' ||
</div> (item.pickedUpAt !== null &&
<ol className={styles.steps}> now - item.pickedUpAt.getTime() < CLOSED_BOX_VISIBLE_MS)
{item.steps.map((step) => ( ? item.pickupBox
<li : null
key={step.sequence} return (
className={styles.step} <li key={item.id} className={styles.item}>
data-status={step.status} <div className={styles.itemHeader}>
> <span className={styles.itemName}>{item.menuItemName}</span>
{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 <button
type="button" type="button"
disabled={pendingItemId === item.id} className={styles.menuButton}
onClick={() => aria-label={`More actions for ${item.menuItemName}`}
void runAction(item.id, () => aria-haspopup="menu"
cancelOrderItem({ popoverTarget={`item-menu-${item.id}`}
data: { orderItemId: item.id, guestAccessToken }, style={{ anchorName: `--item-menu-${item.id}` }}
}),
)
}
> >
Cancel <ActionIcon name="more" />
</button> </button>
)} <div
{item.status === 'ready' && ( id={`item-menu-${item.id}`}
<button popover="auto"
type="button" role="menu"
disabled={pendingItemId === item.id} className={styles.menu}
onClick={() => style={{ positionAnchor: `--item-menu-${item.id}` }}
void runAction(item.id, () =>
markOrderItemPickedUp({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
> >
I picked this up <button
</button> type="button"
role="menuitem"
className={styles.menuItem}
disabled={pendingItemId === item.id}
onClick={() => buyAgain(item)}
>
<ActionIcon name="buyAgain" />
Buy again
</button>
<Link
role="menuitem"
className={styles.menuItem}
to="/help"
search={{
orderId: status.id,
item: item.menuItemName,
status: item.status,
}}
>
<ActionIcon name="help" />
Get help
</Link>
</div>
</div>
{item.options.length > 0 && (
<p className={styles.itemOptions}>
{item.options.map((option) => option.name).join(', ')}
</p>
)} )}
</div> <AnimatePresence initial={false}>
</li> {!isSettled(item) && (
))} <motion.div
key="timeline"
className={styles.timelineWrapper}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={collapseTransition}
>
<ol className={styles.timeline}>
{item.steps.map((step) => (
<li
key={step.sequence}
className={styles.step}
data-status={step.status}
aria-current={
step.status === 'in_progress' ? 'step' : undefined
}
>
<span
className={styles.stepMarker}
aria-hidden="true"
>
<span className={styles.stepDot} />
<StepConnector filled={step.status === 'done'} />
</span>
<span className={styles.stepContent}>
<span className={styles.stepLabel}>
{step.name}
</span>
{step.status === 'queued' &&
step.queuePosition !== null && (
<span className={styles.stepMeta}>
#{step.queuePosition} in line
</span>
)}
</span>
</li>
))}
</ol>
</motion.div>
)}
</AnimatePresence>
{banner && (
<div className={styles.bannerRow}>
<ItemStatusBanner
key={banner.animationKey}
variant={banner.variant}
label={banner.label}
detail={'detail' in banner ? banner.detail : undefined}
animateIn={
!initialBannerKeys.has(
`${item.id}:${banner.animationKey}`,
)
}
/>
</div>
)}
{item.etaSeconds !== null && (
<EtaMessage etaSeconds={item.etaSeconds} />
)}
<div className={styles.actions}>
<AnimatePresence initial={false}>
{pickupBox && (
<HoldButton
key="pickupBox"
className={styles.pickupButton}
disabled={pendingItemId === item.id || boxClosed}
onHold={() =>
void runAction(item.id, () => togglePickupBox(item))
}
exit={pickupButtonExit}
>
<LockIcon open={pickupBox.isOpen} />
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={pickupButtonLabel(pickupBox, boxClosed)}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: reduceMotion ? 0 : 0.18 }}
>
{pickupButtonLabel(pickupBox, boxClosed)}
</motion.span>
</AnimatePresence>
</HoldButton>
)}
</AnimatePresence>
{item.cancellable && (
<button
type="button"
className={styles.cancelButton}
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
cancelOrderItem({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
<ActionIcon name="cancel" />
Cancel
</button>
)}
</div>
</li>
)
})}
</ul> </ul>
</section> </section>
) )
@@ -0,0 +1,156 @@
@use '../../styles/variables' as *;
.page {
max-width: 720px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
@keyframes empty-state-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-1;
padding: $space-5 $space-3;
text-align: center;
border: 1px dashed $color-border;
border-radius: $radius;
background: $color-surface;
@media (prefers-reduced-motion: no-preference) {
animation: empty-state-enter 0.32s cubic-bezier(0.2, 0, 0, 1) both;
}
svg {
width: 2.5rem;
height: 2.5rem;
margin-bottom: $space-2;
color: $color-text-muted;
}
}
.emptyTitle {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.emptyText {
margin: 0 0 $space-3;
color: $color-text-muted;
}
.emptyCta {
display: inline-flex;
align-items: center;
gap: $space-1;
padding: $space-2 $space-4;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
font-weight: 600;
text-decoration: none;
box-shadow: 0 2px 8px color-mix(in srgb, $color-primary 35%, transparent);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px color-mix(in srgb, $color-primary 45%, transparent);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 8px color-mix(in srgb, $color-primary 35%, transparent);
}
}
.emptyCtaArrow {
transition: transform 0.15s ease;
}
.emptyCta:hover .emptyCtaArrow {
transform: translateX(3px);
}
.orders {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.order {
display: block;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
color: inherit;
text-decoration: none;
transition:
border-color 0.15s ease,
opacity 0.28s ease,
transform 0.28s ease;
&:hover {
border-color: $color-primary;
}
}
.orderExiting .order {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
.orderHeader {
display: flex;
justify-content: space-between;
font-weight: 600;
margin-bottom: $space-1;
}
.orderStatus {
text-transform: capitalize;
color: $color-text-muted;
font-weight: 500;
&[data-progress='ready'],
&[data-progress='picked_up'] {
color: $color-success;
}
&[data-progress='cancelled'],
&[data-progress='discarded'] {
color: $color-danger;
}
}
.orderItems {
margin: 0 0 $space-1;
font-size: 0.9rem;
}
.orderMeta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
}
+121
View File
@@ -0,0 +1,121 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { formatPrice, formatStatus } from '#/lib/format.ts'
import styles from './OrdersListView.module.scss'
type OrdersListViewProps = {
orders: Array<CustomerOrderSummary>
}
// Must match the CSS transition duration on .order in
// OrdersListView.module.scss.
const EXIT_DURATION_MS = 280
const OrdersIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 3h6l1 3H8l1-3Z" />
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
<path d="M9 11h6" />
</svg>
)
const formatItems = (items: CustomerOrderSummary['items']) =>
items
.map(({ menuItemName, quantity }) =>
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
)
.join(', ')
export const OrdersListView = ({ orders }: OrdersListViewProps) => {
const [displayedOrders, setDisplayedOrders] = useState(orders)
const [isExiting, setIsExiting] = useState(false)
const hadOrders = useRef(orders.length > 0)
// Orders can go from populated to empty in one prop update (e.g. signing
// out clears them) — keep the previous list on screen just long enough to
// fade it out instead of popping straight to the empty state.
useEffect(() => {
if (orders.length === 0 && hadOrders.current) {
hadOrders.current = false
setIsExiting(true)
const timeout = setTimeout(() => {
setDisplayedOrders([])
setIsExiting(false)
}, EXIT_DURATION_MS)
return () => clearTimeout(timeout)
}
hadOrders.current = orders.length > 0
setDisplayedOrders(orders)
}, [orders])
return (
<section className={styles.page}>
<h1 className={styles.heading}>Your orders</h1>
{displayedOrders.length === 0 ? (
<div className={styles.empty}>
<OrdersIcon />
<p className={styles.emptyTitle}>No orders yet</p>
<p className={styles.emptyText}>
Your placed orders will show up here.
</p>
<Link to="/" className={styles.emptyCta}>
Browse the menu
<span className={styles.emptyCtaArrow} aria-hidden="true">
→
</span>
</Link>
</div>
) : (
<ul className={styles.orders}>
{displayedOrders.map((order) => (
<li
key={order.id}
className={isExiting ? styles.orderExiting : undefined}
>
<Link
to="/orders/$orderId"
params={{ orderId: String(order.id) }}
search={
order.guestAccessToken
? { token: order.guestAccessToken }
: undefined
}
className={styles.order}
style={{ viewTransitionName: `order-card-${order.id}` }}
>
<div className={styles.orderHeader}>
<span className={styles.orderName}>Order #{order.id}</span>
<span
className={styles.orderStatus}
data-progress={order.progress}
>
{formatStatus(order.progress)}
</span>
</div>
<p className={styles.orderItems}>{formatItems(order.items)}</p>
<p className={styles.orderMeta}>
{order.createdAt.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})}{' '}
· {formatPrice(order.totalCents)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { animate, useReducedMotion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
type AnimatedCounterProps = {
value: number
format?: (value: number) => string
className?: string
}
// Tweens the displayed number from its previous value to the new one,
// rather than swapping straight to it, so a price change reads as counting
// up or down. `displayValueRef` tracks the in-flight value so a change that
// interrupts a running tween continues from wherever it currently is
// instead of jumping back to the last committed value.
export const AnimatedCounter = ({
value,
format = String,
className,
}: AnimatedCounterProps) => {
const reduceMotion = useReducedMotion()
const [displayValue, setDisplayValue] = useState(value)
const displayValueRef = useRef(value)
useEffect(() => {
if (reduceMotion) {
displayValueRef.current = value
setDisplayValue(value)
return
}
const controls = animate(displayValueRef.current, value, {
duration: 0.5,
ease: 'easeOut',
onUpdate: (latest) => {
displayValueRef.current = latest
setDisplayValue(latest)
},
})
return () => controls.stop()
}, [value, reduceMotion])
return <span className={className}>{format(Math.round(displayValue))}</span>
}
@@ -0,0 +1,11 @@
.wrapper {
position: relative;
display: inline-grid;
justify-items: center;
overflow: hidden;
vertical-align: middle;
}
.digit {
grid-area: 1 / 1;
}
+68
View File
@@ -0,0 +1,68 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react'
import styles from './AnimatedNumber.module.scss'
type AnimatedNumberProps = {
value: number
format?: (value: number) => string
align?: 'center' | 'start'
className?: string
}
const variants = {
initial: (direction: number) => ({ x: direction * 12, opacity: 0 }),
animate: { x: 0, opacity: 1 },
exit: (direction: number) => ({ x: direction * -12, opacity: 0 }),
}
// A quantity stepper's count slides leftward when incrementing (the new
// digit enters from the right, the old one exits to the left) and
// rightward when decrementing.
//
// `direction` is derived from a prop change during render (the React-docs
// "adjusting state when a prop changes" pattern) rather than mutating a ref
// in the render body — mutating a ref here would already reflect the new
// value by the time `exit`'s "custom" is resolved for the outgoing digit,
// so every transition — increment or decrement — read as "forward".
export const AnimatedNumber = ({
value,
format = String,
align = 'center',
className,
}: AnimatedNumberProps) => {
const reduceMotion = useReducedMotion()
const [previousValue, setPreviousValue] = useState(value)
const [direction, setDirection] = useState(1)
if (value !== previousValue) {
setDirection(value > previousValue ? 1 : -1)
setPreviousValue(value)
}
if (reduceMotion) {
return <span className={className}>{format(value)}</span>
}
return (
<span
className={`${styles.wrapper} ${className ?? ''}`}
style={{ justifyItems: align }}
>
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.span
key={value}
custom={direction}
variants={variants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.15, ease: 'easeOut' }}
className={styles.digit}
>
{format(value)}
</motion.span>
</AnimatePresence>
</span>
)
}
+46
View File
@@ -0,0 +1,46 @@
@use '../../styles/variables' as *;
.footer {
margin-top: $space-5;
padding: $space-4 $space-3;
border-top: 1px solid $color-border;
background: $color-surface;
}
.content {
max-width: 640px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $space-3;
}
.brand {
text-decoration: none;
color: $color-text;
}
.links {
display: flex;
flex-wrap: wrap;
gap: $space-3;
a {
color: $color-text-muted;
text-decoration: none;
font-size: 0.9rem;
&:hover {
color: $color-text;
}
}
}
.copyright {
margin: 0;
width: 100%;
color: $color-text-muted;
font-size: 0.8rem;
}
+28
View File
@@ -0,0 +1,28 @@
import { Link } from '@tanstack/react-router'
import { Logo } from '#/components/ui/Logo.tsx'
import styles from './Footer.module.scss'
type FooterProps = {
brandName: string
}
export const Footer = ({ brandName }: FooterProps) => (
<footer className={styles.footer}>
<div className={styles.content}>
<Link to="/" className={styles.brand}>
<Logo name={brandName} />
</Link>
<nav className={styles.links} aria-label="Footer">
<Link to="/">Menu</Link>
<Link to="/basket">Basket</Link>
<Link to="/orders">Your orders</Link>
<Link to="/help">Help</Link>
</nav>
<p className={styles.copyright}>
© {new Date().getFullYear()} {brandName}
</p>
</div>
</footer>
)
+25
View File
@@ -0,0 +1,25 @@
.holdButton {
position: relative;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.fill {
position: absolute;
inset: 0;
background: currentColor;
opacity: 0.2;
transform: scaleX(0);
transform-origin: left;
pointer-events: none;
}
.content {
position: relative;
display: inline-flex;
align-items: center;
gap: inherit;
}
+79
View File
@@ -0,0 +1,79 @@
import { motion } from 'framer-motion'
import type { HTMLMotionProps } from 'framer-motion'
import { useState } from 'react'
import type { KeyboardEvent, ReactNode } from 'react'
import styles from './HoldButton.module.scss'
type HoldButtonProps = Omit<
HTMLMotionProps<'button'>,
'onClick' | 'children' | 'type'
> & {
onHold: () => void
holdMs?: number
children: ReactNode
}
const isHoldKey = (event: KeyboardEvent) =>
event.key === ' ' || event.key === 'Enter'
// Fires only after being held down for `holdMs`; letting go early, or
// sliding off the button, cancels. Space and Enter work the same way.
export const HoldButton = ({
onHold,
holdMs = 800,
children,
className,
disabled,
...rest
}: HoldButtonProps) => {
const [holding, setHolding] = useState(false)
const start = () => {
if (!disabled) setHolding(true)
}
const stop = () => setHolding(false)
return (
<motion.button
{...rest}
type="button"
disabled={disabled}
className={[styles.holdButton, className].filter(Boolean).join(' ')}
onPointerDown={(event) => {
if (event.button === 0) start()
}}
onPointerUp={stop}
onPointerLeave={stop}
onPointerCancel={stop}
onKeyDown={(event) => {
if (!isHoldKey(event)) return
event.preventDefault()
if (!event.repeat) start()
}}
onKeyUp={(event) => {
if (isHoldKey(event)) stop()
}}
onBlur={stop}
onContextMenu={(event) => event.preventDefault()}
>
<motion.span
className={styles.fill}
aria-hidden="true"
initial={false}
animate={{ scaleX: holding ? 1 : 0 }}
transition={
holding
? { duration: holdMs / 1000, ease: 'linear' }
: { duration: 0.2, ease: 'easeOut' }
}
onAnimationComplete={() => {
if (!holding) return
setHolding(false)
onHold()
}}
/>
<span className={styles.content}>{children}</span>
</motion.button>
)
}
+17
View File
@@ -0,0 +1,17 @@
.logo {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.01em;
}
.faded {
font-weight: 500;
opacity: 0.45;
}
.ghostIcon {
display: inline-block;
height: 0.78em;
width: 0.78em;
vertical-align: -0.05em;
}
+60
View File
@@ -0,0 +1,60 @@
import { useId } from 'react'
import styles from './Logo.module.scss'
type LogoProps = {
name: string
}
const GhostIcon = () => {
const eyeMaskId = useId()
return (
<svg
className={styles.ghostIcon}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<mask id={eyeMaskId}>
<rect x="0" y="0" width="24" height="24" fill="#fff" />
<circle cx="9.5" cy="10.5" r="1.3" />
<circle cx="14.5" cy="10.5" r="1.3" />
</mask>
<path
d="M12 2C7.58 2 4 5.58 4 10v7l2.5 2.2L9 17l3 2.2 3-2.2 2.5 2.2L20 17v-7c0-4.42-3.58-8-8-8Z"
mask={`url(#${eyeMaskId})`}
/>
</svg>
)
}
// The brand's first word fades toward transparent — a literal "ghost" for a
// Kitchen whose name starts with one. When it actually is "Ghost", its "o"
// is swapped for a small ghost mark instead (a differently-named deployment
// just gets the fade).
export const Logo = ({ name }: LogoProps) => {
const [first, ...rest] = name.split(' ')
const restText = rest.join(' ')
if (!restText) return <span className={styles.logo}>{name}</span>
const oIndex = first.toLowerCase().indexOf('o')
return (
<span className={styles.logo}>
<span className={styles.faded}>
{oIndex === -1 ? (
first
) : (
<>
{first.slice(0, oIndex)}
<GhostIcon />
{first.slice(oIndex + 1)}
</>
)}
</span>{' '}
{restText}
</span>
)
}
+37
View File
@@ -0,0 +1,37 @@
@use '../../styles/variables' as *;
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-1;
max-width: 640px;
margin: 0 auto;
padding: $space-5 $space-3;
text-align: center;
}
.title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.text {
margin: 0 0 $space-3;
color: $color-text-muted;
}
.cta {
padding: $space-2 $space-4;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Link } from '@tanstack/react-router'
import styles from './NotFound.module.scss'
export const NotFound = () => (
<div className={styles.wrapper}>
<p className={styles.title}>Page not found</p>
<p className={styles.text}>
The page you're looking for doesn't exist or may have moved.
</p>
<Link to="/" className={styles.cta}>
Browse the menu
</Link>
</div>
)
+92
View File
@@ -1,4 +1,5 @@
import { import {
boolean,
integer, integer,
pgTable, pgTable,
serial, serial,
@@ -55,12 +56,67 @@ export const menuItemSteps = pgTable('menu_item_steps', {
.notNull() .notNull()
.references(() => menuItems.id), .references(() => menuItems.id),
sequence: integer().notNull(), sequence: integer().notNull(),
// Customer-facing label, e.g. "Flattening dough" — distinct from the
// Machine Tag because several Steps in a Recipe can share one Tag (and
// so the same physical Machine) while doing different work on it.
name: text().notNull(),
machineTagId: integer('machine_tag_id') machineTagId: integer('machine_tag_id')
.notNull() .notNull()
.references(() => machineTags.id), .references(() => machineTags.id),
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(), estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
}) })
// A customization a customer can toggle onto a Menu Item at order time,
// e.g. "Extra cheese" (+150 cents) or "No onions" (no price change).
export const menuItemOptions = pgTable('menu_item_options', {
id: serial().primaryKey(),
menuItemId: integer('menu_item_id')
.notNull()
.references(() => menuItems.id),
name: text().notNull(),
priceDeltaCents: integer('price_delta_cents').notNull().default(0),
})
// A shared catalog of allergens (e.g. "Gluten", "Dairy") that Menu Items
// draw from — unlike menuItemOptions, this list is reused across items
// rather than owned by any one of them.
export const allergens = pgTable(
'allergens',
{
id: serial().primaryKey(),
name: text().notNull(),
},
(table) => [uniqueIndex('allergens_name_idx').on(table.name)],
)
// Which Allergens a Menu Item's recipe contains, as ordered by an operator.
export const menuItemAllergens = pgTable(
'menu_item_allergens',
{
id: serial().primaryKey(),
menuItemId: integer('menu_item_id')
.notNull()
.references(() => menuItems.id),
allergenId: integer('allergen_id')
.notNull()
.references(() => allergens.id),
},
(table) => [
uniqueIndex('menu_item_allergens_item_allergen_idx').on(
table.menuItemId,
table.allergenId,
),
],
)
// A locker the customer collects a ready Order Item from, identified to them
// by the number on its door.
export const pickupBoxes = pgTable('pickup_boxes', {
id: serial().primaryKey(),
number: integer().notNull().unique(),
isOpen: boolean('is_open').notNull().default(false),
})
export const orderStatusValues = ['placed', 'paid', 'complete'] as const export const orderStatusValues = ['placed', 'paid', 'complete'] as const
export type OrderStatus = (typeof orderStatusValues)[number] export type OrderStatus = (typeof orderStatusValues)[number]
@@ -85,9 +141,14 @@ export const orderItemStatusValues = [
'ready', 'ready',
'picked_up', 'picked_up',
'cancelled', 'cancelled',
// Left uncollected in its Pickup Box past the pickup window; not refunded.
'discarded',
] as const ] as const
export type OrderItemStatus = (typeof orderItemStatusValues)[number] export type OrderItemStatus = (typeof orderItemStatusValues)[number]
export const orderItemCancelledByValues = ['customer', 'kitchen'] as const
export type OrderItemCancelledBy = (typeof orderItemCancelledByValues)[number]
export const orderItems = pgTable('order_items', { export const orderItems = pgTable('order_items', {
id: serial().primaryKey(), id: serial().primaryKey(),
orderId: integer('order_id') orderId: integer('order_id')
@@ -99,6 +160,13 @@ export const orderItems = pgTable('order_items', {
// Captured at order time so later menu price changes don't rewrite history. // Captured at order time so later menu price changes don't rewrite history.
priceCents: integer('price_cents').notNull(), priceCents: integer('price_cents').notNull(),
status: text().$type<OrderItemStatus>().notNull().default('placed'), status: text().$type<OrderItemStatus>().notNull().default('placed'),
// Only set once status is 'cancelled'.
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
// Set once the item is ready and a Pickup Box is free; kept after pickup.
pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id),
// When the item went into its box, which starts the pickup window.
placedInBoxAt: timestamp('placed_in_box_at'),
pickedUpAt: timestamp('picked_up_at'),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}) })
@@ -112,12 +180,25 @@ export const orderItemSteps = pgTable('order_item_steps', {
.notNull() .notNull()
.references(() => orderItems.id), .references(() => orderItems.id),
sequence: integer().notNull(), sequence: integer().notNull(),
name: text().notNull(),
machineTagId: integer('machine_tag_id') machineTagId: integer('machine_tag_id')
.notNull() .notNull()
.references(() => machineTags.id), .references(() => machineTags.id),
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(), estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
}) })
// A frozen copy of the Menu Item Options selected for a given Order Item —
// same reasoning as orderItemSteps: a later edit to the live option catalog
// (menuItemOptions) never rewrites an Order already placed.
export const orderItemOptions = pgTable('order_item_options', {
id: serial().primaryKey(),
orderItemId: integer('order_item_id')
.notNull()
.references(() => orderItems.id),
name: text().notNull(),
priceDeltaCents: integer('price_delta_cents').notNull(),
})
export const preparationStepStatusValues = [ export const preparationStepStatusValues = [
'queued', 'queued',
'in_progress', 'in_progress',
@@ -135,8 +216,19 @@ export const preparationStepInstances = pgTable('preparation_step_instances', {
.notNull() .notNull()
.references(() => machineTags.id), .references(() => machineTags.id),
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(), estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
// Extra time a Machine reported on top of the estimate after claiming it.
delaySeconds: integer('delay_seconds').notNull().default(0),
status: text().$type<PreparationStepStatus>().notNull().default('queued'), status: text().$type<PreparationStepStatus>().notNull().default('queued'),
machineId: integer('machine_id').references(() => machines.id), machineId: integer('machine_id').references(() => machines.id),
startedAt: timestamp('started_at'), startedAt: timestamp('started_at'),
doneAt: timestamp('done_at'), doneAt: timestamp('done_at'),
}) })
// A message submitted through the Help page's contact form.
export const contactMessages = pgTable('contact_messages', {
id: serial().primaryKey(),
name: text().notNull(),
email: text().notNull(),
message: text().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
+65
View File
@@ -0,0 +1,65 @@
import { sql } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type * as schema from '#/db/schema.ts'
import type { auth as Auth } from '#/lib/auth.ts'
// Drizzle types a raw `.execute()`'s rows as always present, even though
// zero rows is a real possibility at runtime (without
// noUncheckedIndexedAccess) — mirrors the firstRow helper in
// domain/ordering.ts.
const firstRow = <T>(rows: Array<T>): T | undefined => rows[0]
// The `role` field is `input: false` in the Better Auth config (see
// src/lib/auth.ts) so it can't be set through sign-up itself — an admin
// account is always a normal sign-up followed by a direct DB update here,
// never something the sign-up API can be asked to do.
export const seedAdmin = async (
db: NodePgDatabase<typeof schema>,
auth: typeof Auth,
email: string,
password: string,
name = 'Admin',
) => {
let userId: string
try {
const signUp = await auth.api.signUpEmail({ body: { email, password, name } })
userId = signUp.user.id
} catch {
// Already signed up (e.g. this script has run before) — just make sure
// the existing account has the admin role.
const existing = await db.execute<{ id: string }>(
sql`select id from "user" where email = ${email}`,
)
const user = firstRow(existing.rows)
if (!user) throw new Error(`Sign-up failed and no existing user found for ${email}`)
userId = user.id
}
await db.execute(sql`update "user" set role = 'admin' where id = ${userId}`)
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { config } = await import('dotenv')
config({ path: ['.env.local', '.env'] })
const email = process.env.ADMIN_EMAIL
const password = process.env.ADMIN_PASSWORD
if (!email || !password) {
console.error(
'Set ADMIN_EMAIL and ADMIN_PASSWORD (in .env.local or the environment) before running this script.',
)
process.exit(1)
}
// Imported dynamically, after dotenv config runs, since both #/db/index.ts
// and #/lib/auth.ts read DATABASE_URL from process.env at module load time.
const { drizzle } = await import('drizzle-orm/node-postgres')
const schemaModule = await import('#/db/schema.ts')
const { auth } = await import('#/lib/auth.ts')
const db = drizzle(process.env.DATABASE_URL!, { schema: schemaModule })
await seedAdmin(db, auth, email, password, process.env.ADMIN_NAME)
console.log(`Created admin account for ${email}.`)
process.exit(0)
}
+110 -8
View File
@@ -11,6 +11,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'Ghost Kitchen' }) .values({ name: 'Ghost Kitchen' })
.returning() .returning()
const [prepTag] = await db
.insert(schema.machineTags)
.values({ name: 'prep' })
.returning()
const [ovenTag] = await db const [ovenTag] = await db
.insert(schema.machineTags) .insert(schema.machineTags)
.values({ name: 'oven' }) .values({ name: 'oven' })
@@ -24,6 +28,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'assembly' }) .values({ name: 'assembly' })
.returning() .returning()
const [prep1] = await db
.insert(schema.machines)
.values({ name: 'Prep Station 1', machineTagId: prepTag.id })
.returning()
const [oven1] = await db const [oven1] = await db
.insert(schema.machines) .insert(schema.machines)
.values({ name: 'Oven 1', machineTagId: ovenTag.id }) .values({ name: 'Oven 1', machineTagId: ovenTag.id })
@@ -37,6 +45,11 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'Assembly Station 1', machineTagId: assemblyTag.id }) .values({ name: 'Assembly Station 1', machineTagId: assemblyTag.id })
.returning() .returning()
const pickupBoxes = await db
.insert(schema.pickupBoxes)
.values([1, 2, 3, 4].map((number) => ({ number })))
.returning()
const [pizza] = await db const [pizza] = await db
.insert(schema.menuItems) .insert(schema.menuItems)
.values({ .values({
@@ -47,12 +60,59 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
}) })
.returning() .returning()
await db.insert(schema.menuItemSteps).values({ await db.insert(schema.menuItemSteps).values([
menuItemId: pizza.id, {
sequence: 1, menuItemId: pizza.id,
machineTagId: ovenTag.id, sequence: 1,
estimatedDurationSeconds: 600, name: 'Flattening dough',
}) machineTagId: prepTag.id,
estimatedDurationSeconds: 30,
},
{
menuItemId: pizza.id,
sequence: 2,
name: 'Spreading sauce',
machineTagId: prepTag.id,
estimatedDurationSeconds: 20,
},
{
menuItemId: pizza.id,
sequence: 3,
name: 'Adding toppings',
machineTagId: prepTag.id,
estimatedDurationSeconds: 30,
},
{
menuItemId: pizza.id,
sequence: 4,
name: 'Baking in the oven',
machineTagId: ovenTag.id,
estimatedDurationSeconds: 600,
},
{
menuItemId: pizza.id,
sequence: 5,
name: 'Slicing',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 20,
},
{
menuItemId: pizza.id,
sequence: 6,
name: 'Packaging',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 20,
},
])
const pizzaOptions = await db
.insert(schema.menuItemOptions)
.values([
{ menuItemId: pizza.id, name: 'Extra cheese', priceDeltaCents: 150 },
{ menuItemId: pizza.id, name: 'No onions', priceDeltaCents: 0 },
{ menuItemId: pizza.id, name: 'Extra sauce', priceDeltaCents: 0 },
])
.returning()
const [burger] = await db const [burger] = await db
.insert(schema.menuItems) .insert(schema.menuItems)
@@ -68,22 +128,64 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
{ {
menuItemId: burger.id, menuItemId: burger.id,
sequence: 1, sequence: 1,
name: 'Grilling patty',
machineTagId: grillTag.id, machineTagId: grillTag.id,
estimatedDurationSeconds: 240, estimatedDurationSeconds: 240,
}, },
{ {
menuItemId: burger.id, menuItemId: burger.id,
sequence: 2, sequence: 2,
name: 'Assembling burger',
machineTagId: assemblyTag.id, machineTagId: assemblyTag.id,
estimatedDurationSeconds: 90, estimatedDurationSeconds: 90,
}, },
]) ])
const burgerOptions = await db
.insert(schema.menuItemOptions)
.values([
{ menuItemId: burger.id, name: 'Extra cheese', priceDeltaCents: 100 },
{ menuItemId: burger.id, name: 'No pickles', priceDeltaCents: 0 },
{ menuItemId: burger.id, name: 'Extra patty', priceDeltaCents: 250 },
])
.returning()
const allergens = await db
.insert(schema.allergens)
.values([
{ name: 'Gluten' },
{ name: 'Dairy' },
{ name: 'Eggs' },
{ name: 'Soy' },
{ name: 'Peanuts' },
{ name: 'Tree nuts' },
{ name: 'Sesame' },
])
.returning()
const allergenByName = Object.fromEntries(
allergens.map((allergen) => [allergen.name, allergen]),
)
await db.insert(schema.menuItemAllergens).values([
{ menuItemId: pizza.id, allergenId: allergenByName['Gluten'].id },
{ menuItemId: pizza.id, allergenId: allergenByName['Dairy'].id },
{ menuItemId: burger.id, allergenId: allergenByName['Gluten'].id },
{ menuItemId: burger.id, allergenId: allergenByName['Sesame'].id },
])
return { return {
brand, brand,
machineTags: { oven: ovenTag, grill: grillTag, assembly: assemblyTag }, machineTags: {
machines: { oven1, grill1, assembly1 }, prep: prepTag,
oven: ovenTag,
grill: grillTag,
assembly: assemblyTag,
},
machines: { prep1, oven1, grill1, assembly1 },
pickupBoxes,
menuItems: { pizza, burger }, menuItems: { pizza, burger },
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
allergens,
} }
} }
+150
View File
@@ -0,0 +1,150 @@
import { and, eq, sql } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
type Db = NodePgDatabase<typeof schema>
export type MachineTagOption = { id: number; name: string }
export const getMachineTags = async (
db: Db,
): Promise<Array<MachineTagOption>> =>
db.select({ id: schema.machineTags.id, name: schema.machineTags.name }).from(
schema.machineTags,
)
export type MachineStatus = {
id: number
name: string
tagName: string
status: 'busy' | 'idle'
current: {
stepName: string
menuItemName: string
orderId: number
startedAt: Date | null
} | null
}
export type TagQueueSummary = {
tagName: string
idleMachineCount: number
busyMachineCount: number
queuedStepCount: number
}
export type KitchenStatus = {
machines: Array<MachineStatus>
queues: Array<TagQueueSummary>
}
// A snapshot of every Machine's current work, and how many Preparation
// Steps are still waiting per Tag — the admin "Kitchen" page's whole model.
// Read-only: nothing here mutates state (see assignQueuedStepsForTag in
// ordering.ts for how a Machine actually picks up a queued Step).
export const getKitchenStatus = async (db: Db): Promise<KitchenStatus> => {
const machines = await db
.select({
id: schema.machines.id,
name: schema.machines.name,
tagId: schema.machineTags.id,
tagName: schema.machineTags.name,
})
.from(schema.machines)
.innerJoin(
schema.machineTags,
eq(schema.machines.machineTagId, schema.machineTags.id),
)
.orderBy(schema.machineTags.name, schema.machines.name)
const busyInstances = await db
.select({
machineId: schema.preparationStepInstances.machineId,
startedAt: schema.preparationStepInstances.startedAt,
stepName: schema.orderItemSteps.name,
orderId: schema.orders.id,
menuItemName: schema.menuItems.name,
})
.from(schema.preparationStepInstances)
.innerJoin(
schema.orderItems,
eq(schema.preparationStepInstances.orderItemId, schema.orderItems.id),
)
.innerJoin(schema.orders, eq(schema.orderItems.orderId, schema.orders.id))
.innerJoin(
schema.menuItems,
eq(schema.orderItems.menuItemId, schema.menuItems.id),
)
.innerJoin(
schema.orderItemSteps,
and(
eq(
schema.orderItemSteps.orderItemId,
schema.preparationStepInstances.orderItemId,
),
eq(
schema.orderItemSteps.sequence,
schema.preparationStepInstances.sequence,
),
),
)
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
const busyByMachineId = new Map(
busyInstances
.filter((row) => row.machineId !== null)
.map((row) => [row.machineId, row]),
)
const machineStatuses: Array<MachineStatus> = machines.map((machine) => {
const busy = busyByMachineId.get(machine.id)
return {
id: machine.id,
name: machine.name,
tagName: machine.tagName,
status: busy ? 'busy' : 'idle',
current: busy
? {
stepName: busy.stepName,
menuItemName: busy.menuItemName,
orderId: busy.orderId,
startedAt: busy.startedAt,
}
: null,
}
})
const queuedCounts = await db
.select({
machineTagId: schema.preparationStepInstances.machineTagId,
count: sql<number>`count(*)::int`,
})
.from(schema.preparationStepInstances)
.where(eq(schema.preparationStepInstances.status, 'queued'))
.groupBy(schema.preparationStepInstances.machineTagId)
const queuedCountByTagId = new Map(
queuedCounts.map((row) => [row.machineTagId, row.count]),
)
const tagsById = new Map(
machines.map((machine) => [machine.tagId, machine.tagName]),
)
const queues: Array<TagQueueSummary> = [...tagsById.entries()].map(
([tagId, tagName]) => {
const tagMachines = machineStatuses.filter((m) => m.tagName === tagName)
return {
tagName,
idleMachineCount: tagMachines.filter((m) => m.status === 'idle')
.length,
busyMachineCount: tagMachines.filter((m) => m.status === 'busy')
.length,
queuedStepCount: queuedCountByTagId.get(tagId) ?? 0,
}
},
)
return { machines: machineStatuses, queues }
}
+265
View File
@@ -0,0 +1,265 @@
import { eq } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
type Db = NodePgDatabase<typeof schema>
export class MenuItemHasOrdersError extends Error {
constructor() {
super('This item cannot be deleted because it has past Orders')
this.name = 'MenuItemHasOrdersError'
}
}
export type AdminMenuItem = {
id: number
name: string
description: string | null
priceCents: number
options: Array<{ id: number; name: string; priceDeltaCents: number }>
steps: Array<{
id: number
sequence: number
name: string
machineTagId: number
machineTagName: string
estimatedDurationSeconds: number
}>
allergens: Array<{ id: number; name: string }>
}
export type MenuItemInput = {
name: string
description?: string
priceCents: number
options: Array<{ name: string; priceDeltaCents: number }>
steps: Array<{
name: string
machineTagId: number
estimatedDurationSeconds: number
}>
allergenIds: Array<number>
}
export type AllergenOption = { id: number; name: string }
export const getAllergens = async (
db: Db,
): Promise<Array<AllergenOption>> =>
db
.select({ id: schema.allergens.id, name: schema.allergens.name })
.from(schema.allergens)
.orderBy(schema.allergens.name)
const assertHasSteps = (input: MenuItemInput) => {
if (input.steps.length === 0) {
throw new Error('A menu item needs at least one Preparation Step')
}
}
export const getMenuItemsForAdmin = async (
db: Db,
brandId: number,
): Promise<Array<AdminMenuItem>> => {
const items = await db
.select()
.from(schema.menuItems)
.where(eq(schema.menuItems.brandId, brandId))
.orderBy(schema.menuItems.name)
const options = await db.select().from(schema.menuItemOptions)
const steps = await db
.select({
id: schema.menuItemSteps.id,
menuItemId: schema.menuItemSteps.menuItemId,
sequence: schema.menuItemSteps.sequence,
name: schema.menuItemSteps.name,
machineTagId: schema.menuItemSteps.machineTagId,
machineTagName: schema.machineTags.name,
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
})
.from(schema.menuItemSteps)
.innerJoin(
schema.machineTags,
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
)
.orderBy(schema.menuItemSteps.sequence)
const itemAllergens = await db
.select({
menuItemId: schema.menuItemAllergens.menuItemId,
id: schema.allergens.id,
name: schema.allergens.name,
})
.from(schema.menuItemAllergens)
.innerJoin(
schema.allergens,
eq(schema.menuItemAllergens.allergenId, schema.allergens.id),
)
return items.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
priceCents: item.priceCents,
options: options
.filter((option) => option.menuItemId === item.id)
.map((option) => ({
id: option.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
steps: steps
.filter((step) => step.menuItemId === item.id)
.map(({ menuItemId: _menuItemId, ...step }) => step),
allergens: itemAllergens
.filter((allergen) => allergen.menuItemId === item.id)
.map(({ id, name }) => ({ id, name })),
}))
}
export const createMenuItem = async (
db: Db,
brandId: number,
input: MenuItemInput,
): Promise<number> => {
assertHasSteps(input)
return db.transaction(async (tx) => {
const [item] = await tx
.insert(schema.menuItems)
.values({
brandId,
name: input.name,
description: input.description ?? null,
priceCents: input.priceCents,
})
.returning()
if (input.options.length > 0) {
await tx.insert(schema.menuItemOptions).values(
input.options.map((option) => ({
menuItemId: item.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
}
await tx.insert(schema.menuItemSteps).values(
input.steps.map((step, index) => ({
menuItemId: item.id,
sequence: index + 1,
name: step.name,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
)
if (input.allergenIds.length > 0) {
await tx.insert(schema.menuItemAllergens).values(
input.allergenIds.map((allergenId) => ({
menuItemId: item.id,
allergenId,
})),
)
}
return item.id
})
}
// Options, Steps, and Allergens are replaced wholesale on every edit rather
// than diffed — simplest correct behavior for a form that resubmits its
// full list each time, and safe because placed Order Items snapshot their
// own copy (see orderItemSteps/orderItemOptions) so this never rewrites
// order history.
export const updateMenuItem = async (
db: Db,
menuItemId: number,
input: MenuItemInput,
): Promise<void> => {
assertHasSteps(input)
await db.transaction(async (tx) => {
await tx
.update(schema.menuItems)
.set({
name: input.name,
description: input.description ?? null,
priceCents: input.priceCents,
})
.where(eq(schema.menuItems.id, menuItemId))
await tx
.delete(schema.menuItemOptions)
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
await tx
.delete(schema.menuItemSteps)
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
await tx
.delete(schema.menuItemAllergens)
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
if (input.options.length > 0) {
await tx.insert(schema.menuItemOptions).values(
input.options.map((option) => ({
menuItemId,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
}
await tx.insert(schema.menuItemSteps).values(
input.steps.map((step, index) => ({
menuItemId,
sequence: index + 1,
name: step.name,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
)
if (input.allergenIds.length > 0) {
await tx.insert(schema.menuItemAllergens).values(
input.allergenIds.map((allergenId) => ({ menuItemId, allergenId })),
)
}
})
}
const isForeignKeyViolation = (error: unknown) =>
typeof error === 'object' &&
error !== null &&
'cause' in error &&
typeof error.cause === 'object' &&
error.cause !== null &&
'code' in error.cause &&
error.cause.code === '23503'
export const deleteMenuItem = async (
db: Db,
menuItemId: number,
): Promise<void> => {
try {
await db.transaction(async (tx) => {
await tx
.delete(schema.menuItemOptions)
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
await tx
.delete(schema.menuItemSteps)
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
await tx
.delete(schema.menuItemAllergens)
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
// No ON DELETE CASCADE from orderItems.menuItemId — this throws (and
// rolls back the deletes above) if any Order ever referenced it.
await tx.delete(schema.menuItems).where(eq(schema.menuItems.id, menuItemId))
})
} catch (error) {
if (isForeignKeyViolation(error)) throw new MenuItemHasOrdersError()
throw error
}
}
+724 -43
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray } from 'drizzle-orm' import { and, asc, desc, eq, inArray, isNotNull, isNull, lt } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts' import * as schema from '#/db/schema.ts'
@@ -32,6 +32,27 @@ export class OrderItemNotReadyError extends Error {
} }
} }
export class NoPickupBoxYetError extends Error {
constructor() {
super('No pickup box is free for this item yet')
this.name = 'NoPickupBoxYetError'
}
}
export class PickupWindowExpiredError extends Error {
constructor() {
super('This item was not picked up in time and has been discarded')
this.name = 'PickupWindowExpiredError'
}
}
export class PickupBoxNotOpenError extends Error {
constructor() {
super('Open the pickup box before closing it')
this.name = 'PickupBoxNotOpenError'
}
}
export class NoStepInProgressError extends Error { export class NoStepInProgressError extends Error {
constructor() { constructor() {
super('This item has no Preparation Step currently in progress') super('This item has no Preparation Step currently in progress')
@@ -53,9 +74,19 @@ export type MenuItemWithRecipe = {
priceCents: number priceCents: number
steps: Array<{ steps: Array<{
sequence: number sequence: number
name: string
machineTag: string machineTag: string
estimatedDurationSeconds: number estimatedDurationSeconds: number
}> }>
options: Array<{
id: number
name: string
priceDeltaCents: number
}>
allergens: Array<{
id: number
name: string
}>
} }
export const getMenu = async ( export const getMenu = async (
@@ -67,10 +98,13 @@ export const getMenu = async (
.from(schema.menuItems) .from(schema.menuItems)
.where(eq(schema.menuItems.brandId, brandId)) .where(eq(schema.menuItems.brandId, brandId))
const menuItemIds = items.map((item) => item.id)
const steps = await db const steps = await db
.select({ .select({
menuItemId: schema.menuItemSteps.menuItemId, menuItemId: schema.menuItemSteps.menuItemId,
sequence: schema.menuItemSteps.sequence, sequence: schema.menuItemSteps.sequence,
name: schema.menuItemSteps.name,
machineTag: schema.machineTags.name, machineTag: schema.machineTags.name,
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds, estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
}) })
@@ -79,14 +113,33 @@ export const getMenu = async (
schema.machineTags, schema.machineTags,
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id), eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
) )
.where( .where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
inArray(
schema.menuItemSteps.menuItemId,
items.map((item) => item.id),
),
)
.orderBy(asc(schema.menuItemSteps.sequence)) .orderBy(asc(schema.menuItemSteps.sequence))
const options =
menuItemIds.length === 0
? []
: await db
.select()
.from(schema.menuItemOptions)
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
const itemAllergens =
menuItemIds.length === 0
? []
: await db
.select({
menuItemId: schema.menuItemAllergens.menuItemId,
id: schema.allergens.id,
name: schema.allergens.name,
})
.from(schema.menuItemAllergens)
.innerJoin(
schema.allergens,
eq(schema.menuItemAllergens.allergenId, schema.allergens.id),
)
.where(inArray(schema.menuItemAllergens.menuItemId, menuItemIds))
return items.map((item) => ({ return items.map((item) => ({
id: item.id, id: item.id,
name: item.name, name: item.name,
@@ -94,11 +147,18 @@ export const getMenu = async (
priceCents: item.priceCents, priceCents: item.priceCents,
steps: steps steps: steps
.filter((step) => step.menuItemId === item.id) .filter((step) => step.menuItemId === item.id)
.map(({ sequence, machineTag, estimatedDurationSeconds }) => ({ .map(({ sequence, name, machineTag, estimatedDurationSeconds }) => ({
sequence, sequence,
name,
machineTag, machineTag,
estimatedDurationSeconds, estimatedDurationSeconds,
})), })),
options: options
.filter((option) => option.menuItemId === item.id)
.map(({ id, name, priceDeltaCents }) => ({ id, name, priceDeltaCents })),
allergens: itemAllergens
.filter((allergen) => allergen.menuItemId === item.id)
.map(({ id, name }) => ({ id, name })),
})) }))
} }
@@ -199,6 +259,7 @@ const initializeOrderItemRecipe = async (
liveSteps.map((step) => ({ liveSteps.map((step) => ({
orderItemId, orderItemId,
sequence: step.sequence, sequence: step.sequence,
name: step.name,
machineTagId: step.machineTagId, machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds, estimatedDurationSeconds: step.estimatedDurationSeconds,
})), })),
@@ -227,7 +288,7 @@ const initializeOrderItemRecipe = async (
} }
export type PlaceOrderInput = { export type PlaceOrderInput = {
items: Array<{ menuItemId: number }> items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
customer: customer:
| { type: 'guest'; name: string; contact: string } | { type: 'guest'; name: string; contact: string }
| { type: 'account'; customerId: string } | { type: 'account'; customerId: string }
@@ -272,8 +333,39 @@ export const placeOrder = async (
} }
} }
const totalCents = input.items.reduce( const allSelectedOptionIds = [
(sum, item) => sum + menuItemById.get(item.menuItemId)!.priceCents, ...new Set(input.items.flatMap((item) => item.selectedOptionIds ?? [])),
]
const selectedOptionRows =
allSelectedOptionIds.length === 0
? []
: await db
.select()
.from(schema.menuItemOptions)
.where(inArray(schema.menuItemOptions.id, allSelectedOptionIds))
const optionById = new Map(selectedOptionRows.map((row) => [row.id, row]))
// Resolve each Order Item's selected Options up front, validating that
// every id is a real Option belonging to that specific Menu Item — not,
// say, a Burger option applied to a Pizza — before anyone is charged.
const resolvedItems = input.items.map((item) => {
const options = (item.selectedOptionIds ?? []).map((optionId) => {
const option = optionById.get(optionId)
if (!option || option.menuItemId !== item.menuItemId) {
throw new Error(
`Option ${optionId} is not valid for menu item ${item.menuItemId}`,
)
}
return option
})
const priceCents =
menuItemById.get(item.menuItemId)!.priceCents +
options.reduce((sum, option) => sum + option.priceDeltaCents, 0)
return { menuItemId: item.menuItemId, options, priceCents }
})
const totalCents = resolvedItems.reduce(
(sum, item) => sum + item.priceCents,
0, 0,
) )
@@ -309,22 +401,31 @@ export const placeOrder = async (
const tagsToAssign = new Set<number>() const tagsToAssign = new Set<number>()
for (const item of input.items) { for (const item of resolvedItems) {
const menuItem = menuItemById.get(item.menuItemId)!
const [orderItem] = await tx const [orderItem] = await tx
.insert(schema.orderItems) .insert(schema.orderItems)
.values({ .values({
orderId: order.id, orderId: order.id,
menuItemId: menuItem.id, menuItemId: item.menuItemId,
priceCents: menuItem.priceCents, priceCents: item.priceCents,
status: 'paid', status: 'paid',
}) })
.returning() .returning()
if (item.options.length > 0) {
await tx.insert(schema.orderItemOptions).values(
item.options.map((option) => ({
orderItemId: orderItem.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
}
const step = await initializeOrderItemRecipe( const step = await initializeOrderItemRecipe(
tx, tx,
orderItem.id, orderItem.id,
menuItem.id, item.menuItemId,
) )
tagsToAssign.add(step.machineTagId) tagsToAssign.add(step.machineTagId)
} }
@@ -352,7 +453,11 @@ export const placeOrder = async (
} }
} }
export type OrderAccess = { customerId?: string; guestAccessToken?: string } export type OrderAccess = {
customerId?: string
guestAccessToken?: string
isAdmin?: boolean
}
const assertOrderAccess = ( const assertOrderAccess = (
order: Pick< order: Pick<
@@ -361,6 +466,7 @@ const assertOrderAccess = (
>, >,
access: OrderAccess, access: OrderAccess,
) => { ) => {
if (access.isAdmin) return
if (order.customerId) { if (order.customerId) {
if (access.customerId === order.customerId) return if (access.customerId === order.customerId) return
} else if (order.guestAccessToken) { } else if (order.guestAccessToken) {
@@ -392,7 +498,10 @@ const updateOrderCompleteIfEverySiblingIsDone = async (
.where(eq(schema.orderItems.orderId, orderId)) .where(eq(schema.orderItems.orderId, orderId))
const orderComplete = siblingItems.every( const orderComplete = siblingItems.every(
(item) => item.status === 'picked_up' || item.status === 'cancelled', (item) =>
item.status === 'picked_up' ||
item.status === 'cancelled' ||
item.status === 'discarded',
) )
if (orderComplete) { if (orderComplete) {
@@ -429,7 +538,11 @@ export const cancelOrderItem = async (
if (orderItem.status === 'cancelled') { if (orderItem.status === 'cancelled') {
throw new OrderItemNotCancellableError('it has already been cancelled') throw new OrderItemNotCancellableError('it has already been cancelled')
} }
if (orderItem.status === 'ready' || orderItem.status === 'picked_up') { if (
orderItem.status === 'ready' ||
orderItem.status === 'picked_up' ||
orderItem.status === 'discarded'
) {
throw new OrderItemNotCancellableError('it is already done cooking') throw new OrderItemNotCancellableError('it is already done cooking')
} }
@@ -464,14 +577,12 @@ export const cancelOrderItem = async (
.returning({ id: schema.preparationStepInstances.id }) .returning({ id: schema.preparationStepInstances.id })
if (deletedSteps.length === 0) { if (deletedSteps.length === 0) {
throw new OrderItemNotCancellableError( throw new OrderItemNotCancellableError('it has already started cooking')
'it has already started cooking',
)
} }
await tx await tx
.update(schema.orderItems) .update(schema.orderItems)
.set({ status: 'cancelled' }) .set({ status: 'cancelled', cancelledBy: 'customer' })
.where(eq(schema.orderItems.id, orderItemId)) .where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId) await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
@@ -534,6 +645,7 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
.update(schema.orderItems) .update(schema.orderItems)
.set({ status: 'ready' }) .set({ status: 'ready' })
.where(eq(schema.orderItems.id, orderItemId)) .where(eq(schema.orderItems.id, orderItemId))
await assignFreePickupBoxes(tx)
} }
// Freeing this machine may unblock another queued Step for the same Tag. // Freeing this machine may unblock another queued Step for the same Tag.
@@ -541,37 +653,301 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
}) })
} }
export const markOrderItemPickedUp = async ( // A Machine reporting that the Step it's working on will take longer than
// estimated. Delays accumulate, and the extra time counts toward the ETA.
export const delayCurrentStep = async (
db: Db,
orderItemId: number,
delaySeconds: number,
) => {
const currentStep = await getCurrentStepInstance(db, orderItemId)
if (!currentStep || currentStep.status !== 'in_progress') {
throw new NoStepInProgressError()
}
await db
.update(schema.preparationStepInstances)
.set({ delaySeconds: currentStep.delaySeconds + delaySeconds })
.where(eq(schema.preparationStepInstances.id, currentStep.id))
}
// The kitchen giving up on an Order Item (e.g. a Machine fault), which unlike
// a customer cancellation is allowed at any point before it's ready — even
// mid-Step. The customer is refunded all the same.
export const cancelOrderItemByKitchen = async (
db: Db,
paymentProvider: PaymentProvider,
orderItemId: number,
) => {
const orderItem = firstRow(
await db
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
if (orderItem.status !== 'placed' && orderItem.status !== 'paid') {
throw new OrderItemNotCancellableError('it is no longer being prepared')
}
const order = firstRow(
await db
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
// Refund first, mirroring cancelOrderItem: a failed refund records nothing.
if (order.paymentChargeId) {
await paymentProvider.refund(order.paymentChargeId, orderItem.priceCents)
}
await db.transaction(async (tx) => {
const removedSteps = await tx
.delete(schema.preparationStepInstances)
.where(
and(
eq(schema.preparationStepInstances.orderItemId, orderItemId),
inArray(schema.preparationStepInstances.status, [
'queued',
'in_progress',
]),
),
)
.returning({ machineTagId: schema.preparationStepInstances.machineTagId })
await tx
.update(schema.orderItems)
.set({ status: 'cancelled', cancelledBy: 'kitchen' })
.where(eq(schema.orderItems.id, orderItemId))
for (const step of removedSteps) {
await assignQueuedStepsForTag(tx, step.machineTagId)
}
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
})
}
type SimulatedStep = Pick<
typeof schema.preparationStepInstances.$inferSelect,
'estimatedDurationSeconds' | 'delaySeconds'
>
export type KitchenSimulationPolicy = {
stepDurationMs: (step: SimulatedStep) => number
// What happens to a Step once its simulated duration is up.
pickOutcome: (
step: SimulatedStep,
) =>
| { type: 'finish' }
| { type: 'delay'; delaySeconds: number }
| { type: 'cancel' }
}
// Plays the part of every Machine at once for the dev kitchen simulation:
// settles each in-progress Step whose simulated duration has elapsed since
// its Machine claimed it, whichever Order it belongs to — otherwise a Step
// nobody is simulating would hold its Machine forever and starve the queue.
export const runKitchenSimulationTick = async (
db: Db,
paymentProvider: PaymentProvider,
policy: KitchenSimulationPolicy,
now = new Date(),
) => {
await discardUncollectedItems(db, now)
const inProgressSteps = await db
.select()
.from(schema.preparationStepInstances)
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
const elapsedSteps = inProgressSteps.filter(
(step) =>
step.startedAt !== null &&
now.getTime() - step.startedAt.getTime() >= policy.stepDurationMs(step),
)
for (const step of elapsedSteps) {
const outcome = policy.pickOutcome(step)
if (outcome.type === 'delay') {
await delayCurrentStep(db, step.orderItemId, outcome.delaySeconds)
} else if (outcome.type === 'cancel') {
await cancelOrderItemByKitchen(db, paymentProvider, step.orderItemId)
} else {
await advanceOrderItemStep(db, step.orderItemId)
}
}
}
// Hands each free Pickup Box to a ready Order Item still waiting for one,
// oldest item first.
const assignFreePickupBoxes = async (db: DbOrTx) => {
const occupiedBoxIds = new Set(
(
await db
.select({ pickupBoxId: schema.orderItems.pickupBoxId })
.from(schema.orderItems)
.where(
and(
eq(schema.orderItems.status, 'ready'),
isNotNull(schema.orderItems.pickupBoxId),
),
)
).map((row) => row.pickupBoxId),
)
const freeBoxes = (
await db
.select()
.from(schema.pickupBoxes)
.orderBy(asc(schema.pickupBoxes.number))
).filter((box) => !occupiedBoxIds.has(box.id))
if (freeBoxes.length === 0) return
const waitingItems = await db
.select()
.from(schema.orderItems)
.where(
and(
eq(schema.orderItems.status, 'ready'),
isNull(schema.orderItems.pickupBoxId),
),
)
.orderBy(asc(schema.orderItems.id))
.limit(freeBoxes.length)
for (const [index, item] of waitingItems.entries()) {
await db
.update(schema.orderItems)
.set({ pickupBoxId: freeBoxes[index].id, placedInBoxAt: new Date() })
.where(eq(schema.orderItems.id, item.id))
}
}
export const PICKUP_WINDOW_MS = 15 * 60 * 1000
// Empties every box whose item has sat uncollected past the pickup window,
// without a refund, and hands the freed boxes on. An open box is skipped: its
// customer is taking the item out right now. Nothing runs this on a schedule
// yet, so reads and box actions call it first to bring state up to date.
export const discardUncollectedItems = async (db: DbOrTx, now = new Date()) => {
const expiredItems = await db
.select({
id: schema.orderItems.id,
orderId: schema.orderItems.orderId,
})
.from(schema.orderItems)
.innerJoin(
schema.pickupBoxes,
eq(schema.orderItems.pickupBoxId, schema.pickupBoxes.id),
)
.where(
and(
eq(schema.orderItems.status, 'ready'),
eq(schema.pickupBoxes.isOpen, false),
lt(
schema.orderItems.placedInBoxAt,
new Date(now.getTime() - PICKUP_WINDOW_MS),
),
),
)
if (expiredItems.length === 0) return
await db
.update(schema.orderItems)
.set({ status: 'discarded' })
.where(
inArray(
schema.orderItems.id,
expiredItems.map((item) => item.id),
),
)
for (const orderId of new Set(expiredItems.map((item) => item.orderId))) {
await updateOrderCompleteIfEverySiblingIsDone(db, orderId)
}
await assignFreePickupBoxes(db)
}
const getPickupBoxForReadyItem = async (
tx: Tx,
orderItemId: number,
access: OrderAccess,
) => {
const orderItem = firstRow(
await tx
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
const order = firstRow(
await tx
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
assertOrderAccess(order, access)
if (orderItem.status === 'discarded') throw new PickupWindowExpiredError()
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
const box = firstRow(
await tx
.select()
.from(schema.pickupBoxes)
.where(eq(schema.pickupBoxes.id, orderItem.pickupBoxId)),
)
if (!box) throw new Error('Pickup box not found')
return { orderItem, box }
}
// Stand-in for unlocking the physical box door until hardware integration
// lands, like the kitchen simulation does for Machines.
export const openPickupBox = async (
db: Db, db: Db,
orderItemId: number, orderItemId: number,
access: OrderAccess, access: OrderAccess,
) => { ) => {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const orderItem = firstRow( await discardUncollectedItems(tx)
await tx const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
.select() await tx
.from(schema.orderItems) .update(schema.pickupBoxes)
.where(eq(schema.orderItems.id, orderItemId)), .set({ isOpen: true })
) .where(eq(schema.pickupBoxes.id, box.id))
if (!orderItem) throw new Error('Order item not found') })
}
const order = firstRow( // Closing the box after taking the item out is what counts as picking it up.
await tx export const closePickupBox = async (
.select() db: Db,
.from(schema.orders) orderItemId: number,
.where(eq(schema.orders.id, orderItem.orderId)), access: OrderAccess,
) => {
await db.transaction(async (tx) => {
await discardUncollectedItems(tx)
const { orderItem, box } = await getPickupBoxForReadyItem(
tx,
orderItemId,
access,
) )
if (!order) throw new Error('Order not found') if (!box.isOpen) throw new PickupBoxNotOpenError()
assertOrderAccess(order, access)
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
await tx
.update(schema.pickupBoxes)
.set({ isOpen: false })
.where(eq(schema.pickupBoxes.id, box.id))
await tx await tx
.update(schema.orderItems) .update(schema.orderItems)
.set({ status: 'picked_up' }) .set({ status: 'picked_up', pickedUpAt: new Date() })
.where(eq(schema.orderItems.id, orderItemId)) .where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId) await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
await assignFreePickupBoxes(tx)
}) })
} }
@@ -579,14 +955,29 @@ export type OrderItemStatusView = {
id: number id: number
menuItemName: string menuItemName: string
status: schema.OrderItemStatus status: schema.OrderItemStatus
cancelledBy: schema.OrderItemCancelledBy | null
cancellable: boolean cancellable: boolean
steps: Array<{ steps: Array<{
sequence: number sequence: number
name: string
machineTag: string machineTag: string
estimatedDurationSeconds: number estimatedDurationSeconds: number
status: 'done' | 'in_progress' | 'queued' | 'pending' status: 'done' | 'in_progress' | 'queued' | 'pending'
queuePosition: number | null queuePosition: number | null
}> }>
options: Array<{ name: string; priceDeltaCents: number }>
menuItemId: number
// The Menu Item's current Options matching this item's by name — editing a
// Menu Item recreates its Options under new ids, so the ids at order time
// can't be reused. Options since removed from the menu are left out.
buyAgainOptionIds: Array<number>
// The box a ready item waits in, still reported once it's picked up or
// discarded (with isOpen false, since the box may since have gone to
// another item).
pickupBox: { number: number; isOpen: boolean } | null
pickedUpAt: Date | null
// When the box will be emptied if the item is still there.
pickupDeadline: Date | null
etaSeconds: number | null etaSeconds: number | null
} }
@@ -595,6 +986,8 @@ export const getOrderStatus = async (
orderId: number, orderId: number,
access: OrderAccess, access: OrderAccess,
) => { ) => {
await discardUncollectedItems(db)
const order = firstRow( const order = firstRow(
await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)), await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)),
) )
@@ -618,6 +1011,32 @@ export const getOrderStatus = async (
).map((row) => [row.id, row]), ).map((row) => [row.id, row]),
) )
const optionRows =
orderItemIds.length === 0
? []
: await db
.select()
.from(schema.orderItemOptions)
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
const pickupBoxIds = items
.map((item) => item.pickupBoxId)
.filter((id) => id !== null)
const pickupBoxesById = new Map(
(pickupBoxIds.length === 0
? []
: await db
.select()
.from(schema.pickupBoxes)
.where(inArray(schema.pickupBoxes.id, pickupBoxIds))
).map((box) => [box.id, box]),
)
const currentOptionRows = await db
.select()
.from(schema.menuItemOptions)
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
const recipeRows = const recipeRows =
orderItemIds.length === 0 orderItemIds.length === 0
? [] ? []
@@ -625,6 +1044,7 @@ export const getOrderStatus = async (
.select({ .select({
orderItemId: schema.orderItemSteps.orderItemId, orderItemId: schema.orderItemSteps.orderItemId,
sequence: schema.orderItemSteps.sequence, sequence: schema.orderItemSteps.sequence,
name: schema.orderItemSteps.name,
machineTag: schema.machineTags.name, machineTag: schema.machineTags.name,
machineTagId: schema.orderItemSteps.machineTagId, machineTagId: schema.orderItemSteps.machineTagId,
estimatedDurationSeconds: estimatedDurationSeconds:
@@ -710,6 +1130,7 @@ export const getOrderStatus = async (
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
return { return {
sequence: step.sequence, sequence: step.sequence,
name: step.name,
machineTag: step.machineTag, machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds, estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'pending' as const, status: 'pending' as const,
@@ -720,6 +1141,7 @@ export const getOrderStatus = async (
if (instance.status === 'done') { if (instance.status === 'done') {
return { return {
sequence: step.sequence, sequence: step.sequence,
name: step.name,
machineTag: step.machineTag, machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds, estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'done' as const, status: 'done' as const,
@@ -733,12 +1155,15 @@ export const getOrderStatus = async (
: 0 : 0
if (etaSeconds !== null) { if (etaSeconds !== null) {
etaSeconds += Math.max( etaSeconds += Math.max(
step.estimatedDurationSeconds - elapsedSeconds, step.estimatedDurationSeconds +
instance.delaySeconds -
elapsedSeconds,
0, 0,
) )
} }
return { return {
sequence: step.sequence, sequence: step.sequence,
name: step.name,
machineTag: step.machineTag, machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds, estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'in_progress' as const, status: 'in_progress' as const,
@@ -765,6 +1190,7 @@ export const getOrderStatus = async (
return { return {
sequence: step.sequence, sequence: step.sequence,
name: step.name,
machineTag: step.machineTag, machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds, estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'queued' as const, status: 'queued' as const,
@@ -775,23 +1201,56 @@ export const getOrderStatus = async (
if ( if (
item.status === 'ready' || item.status === 'ready' ||
item.status === 'picked_up' || item.status === 'picked_up' ||
item.status === 'cancelled' item.status === 'cancelled' ||
item.status === 'discarded'
) { ) {
etaSeconds = null etaSeconds = null
} }
const currentInstance = instances.find((row) => row.status !== 'done') const currentInstance = instances.find((row) => row.status !== 'done')
const options = optionRows.filter(
(option) => option.orderItemId === item.id,
)
const pickupBox =
item.pickupBoxId !== null
? pickupBoxesById.get(item.pickupBoxId)
: undefined
return { return {
id: item.id, id: item.id,
menuItemName: menuItem?.name ?? '', menuItemName: menuItem?.name ?? '',
status: item.status, status: item.status,
cancelledBy: item.cancelledBy,
cancellable: cancellable:
item.status !== 'cancelled' && item.status !== 'cancelled' &&
item.status !== 'ready' && item.status !== 'ready' &&
item.status !== 'picked_up' && item.status !== 'picked_up' &&
item.status !== 'discarded' &&
currentInstance?.status === 'queued', currentInstance?.status === 'queued',
steps, steps,
options: options.map(({ name, priceDeltaCents }) => ({
name,
priceDeltaCents,
})),
menuItemId: item.menuItemId,
buyAgainOptionIds: currentOptionRows
.filter(
(current) =>
current.menuItemId === item.menuItemId &&
options.some((option) => option.name === current.name),
)
.map((current) => current.id),
pickupBox: pickupBox
? {
number: pickupBox.number,
isOpen: item.status === 'ready' && pickupBox.isOpen,
}
: null,
pickedUpAt: item.pickedUpAt,
pickupDeadline:
item.status === 'ready' && item.placedInBoxAt
? new Date(item.placedInBoxAt.getTime() + PICKUP_WINDOW_MS)
: null,
etaSeconds, etaSeconds,
} }
}) })
@@ -799,6 +1258,228 @@ export const getOrderStatus = async (
return { return {
id: order.id, id: order.id,
status: order.status, status: order.status,
createdAt: order.createdAt,
items: itemViews, items: itemViews,
} }
} }
// What a customer would say their Order is doing right now, derived from its
// Order Items. The Order's own status can't say this: it stays "paid" while
// items cook and only turns "complete" once every item is picked up or
// cancelled.
export const getOrderProgress = (
items: Array<{ status: schema.OrderItemStatus; started: boolean }>,
) => {
const activeItems = items.filter(
(item) => item.status !== 'cancelled' && item.status !== 'discarded',
)
const activeStatuses = activeItems.map((item) => item.status)
if (activeStatuses.length === 0) {
return items.some((item) => item.status === 'discarded')
? 'discarded'
: 'cancelled'
}
if (activeStatuses.every((status) => status === 'picked_up'))
return 'picked_up'
if (
activeStatuses.every(
(status) => status === 'ready' || status === 'picked_up',
)
) {
return 'ready'
}
if (activeStatuses.includes('ready')) return 'partly_ready'
if (activeItems.some((item) => item.started || item.status === 'picked_up')) {
return 'preparing'
}
return 'not_started'
}
export type OrderProgress = ReturnType<typeof getOrderProgress>
export type CustomerOrderSummary = {
id: number
status: schema.OrderStatus
progress: OrderProgress
createdAt: Date
totalCents: number
items: Array<{ menuItemName: string; quantity: number }>
// Only set for a guest Order — its detail page needs this back as a
// search param to prove access, since there's no session to check.
guestAccessToken: string | null
}
const summarizeOrders = async (
db: Db,
orders: Array<typeof schema.orders.$inferSelect>,
): Promise<Array<CustomerOrderSummary>> => {
if (orders.length === 0) return []
const items = await db
.select({
id: schema.orderItems.id,
orderId: schema.orderItems.orderId,
menuItemName: schema.menuItems.name,
priceCents: schema.orderItems.priceCents,
status: schema.orderItems.status,
})
.from(schema.orderItems)
.innerJoin(
schema.menuItems,
eq(schema.orderItems.menuItemId, schema.menuItems.id),
)
.where(
inArray(
schema.orderItems.orderId,
orders.map((order) => order.id),
),
)
const startedSteps = await db
.selectDistinct({
orderItemId: schema.preparationStepInstances.orderItemId,
})
.from(schema.preparationStepInstances)
.where(
and(
inArray(
schema.preparationStepInstances.orderItemId,
items.map((item) => item.id),
),
inArray(schema.preparationStepInstances.status, [
'in_progress',
'done',
]),
),
)
const startedItemIds = new Set(startedSteps.map((row) => row.orderItemId))
return orders.map((order) => {
const orderItems = items.filter((item) => item.orderId === order.id)
const quantityByMenuItemName = new Map<string, number>()
for (const item of orderItems) {
quantityByMenuItemName.set(
item.menuItemName,
(quantityByMenuItemName.get(item.menuItemName) ?? 0) + 1,
)
}
return {
id: order.id,
status: order.status,
progress: getOrderProgress(
orderItems.map((item) => ({
status: item.status,
started: startedItemIds.has(item.id),
})),
),
createdAt: order.createdAt,
totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0),
items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({
menuItemName,
quantity,
})),
guestAccessToken: order.guestAccessToken,
}
})
}
// A signed-in customer's own Order history — guest Orders aren't included
// since they have no account to list under, only a per-Order access token
// (see getGuestOrderSummaries for how a guest's own history is looked up).
export const getOrdersForCustomer = async (
db: Db,
customerId: string,
): Promise<Array<CustomerOrderSummary>> => {
await discardUncollectedItems(db)
const orders = await db
.select()
.from(schema.orders)
.where(eq(schema.orders.customerId, customerId))
.orderBy(desc(schema.orders.createdAt))
return summarizeOrders(db, orders)
}
// Called when an account is deleted. orders.customerId is a soft reference
// to better-auth's user table (no DB-level FK), so deleting the user row
// would otherwise just leave those orders pointing at an id that no longer
// exists. Detaching them instead keeps the Order history intact for
// kitchen/admin purposes, just no longer attributed to an account.
export const detachCustomerOrders = async (
db: Db,
customerId: string,
): Promise<void> => {
await db
.update(schema.orders)
.set({ customerId: null })
.where(eq(schema.orders.customerId, customerId))
}
export type AdminOrderSummary = CustomerOrderSummary & {
customer:
| { type: 'account'; customerId: string }
| { type: 'guest'; name: string; contact: string }
}
// Every Order across every customer, for the admin dashboard — unlike
// getOrdersForCustomer/getGuestOrderSummaries, this isn't scoped to one
// customer's own access, so it must only ever be called once the caller has
// already been checked for admin access.
export const getAllOrders = async (
db: Db,
): Promise<Array<AdminOrderSummary>> => {
await discardUncollectedItems(db)
const orders = await db
.select()
.from(schema.orders)
.orderBy(desc(schema.orders.createdAt))
const summaries = await summarizeOrders(db, orders)
const ordersById = new Map(orders.map((order) => [order.id, order]))
return summaries.map((summary) => {
const order = ordersById.get(summary.id)!
const customer = order.customerId
? ({ type: 'account', customerId: order.customerId } as const)
: ({
type: 'guest',
name: order.guestName ?? 'Guest',
contact: order.guestContact ?? '',
} as const)
return { ...summary, customer }
})
}
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
// A guest's Order history, looked up from the (orderId, guestAccessToken)
// pairs their browser remembered locally after each placement — there's no
// account to query by, so each ref's token stands in for one. A ref whose
// token doesn't match (stale, tampered, or the Order somehow isn't a guest
// Order) is silently dropped rather than failing the whole lookup.
export const getGuestOrderSummaries = async (
db: Db,
refs: Array<GuestOrderRef>,
): Promise<Array<CustomerOrderSummary>> => {
if (refs.length === 0) return []
await discardUncollectedItems(db)
const tokenByOrderId = new Map(
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
)
const orders = await db
.select()
.from(schema.orders)
.where(inArray(schema.orders.id, [...tokenByOrderId.keys()]))
const authorizedOrders = orders.filter(
(order) =>
order.guestAccessToken !== null &&
tokenByOrderId.get(order.id) === order.guestAccessToken,
)
const summaries = await summarizeOrders(db, authorizedOrders)
return summaries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}
@@ -1,44 +1,136 @@
@use '../../styles/variables' as *; @use '../../styles/variables' as *;
.avatar { .wrapper {
height: 2rem; position: relative;
width: 2rem;
background: $color-surface;
} }
.avatarPulse { .trigger {
animation: pulse 1.5s ease-in-out infinite;
}
.avatarInitial {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.75rem;
font-weight: 500;
color: $color-text-muted;
}
.session {
display: flex;
align-items: center;
gap: $space-2;
}
.signOut {
flex: 1;
height: 2.25rem; height: 2.25rem;
padding: 0 $space-3; width: 2.25rem;
font-size: 0.875rem; padding: 0;
font-weight: 500;
background: $color-background;
color: $color-text;
border: 1px solid $color-border; border: 1px solid $color-border;
border-radius: 50%;
background: $color-background;
color: $color-text-muted;
cursor: pointer;
overflow: hidden;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
&:hover { &:hover {
background: $color-surface; background: $color-surface;
} }
svg {
height: 1.25rem;
width: 1.25rem;
}
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
.avatarImage {
height: 100%;
width: 100%;
object-fit: cover;
}
.avatarInitial {
font-size: 0.85rem;
font-weight: 600;
color: $color-text;
}
.menu {
position: absolute;
top: calc(100% + #{$space-2});
right: 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 11rem;
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
z-index: 10;
}
.menuLabel {
padding: $space-2;
border-bottom: 1px solid $color-border;
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: $color-text;
}
.menuButton {
display: flex;
align-items: center;
gap: $space-2;
width: 100%;
padding: $space-2;
border: none;
border-radius: calc($radius - 2px);
background: none;
color: $color-text;
font-size: 0.875rem;
font-weight: 500;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
}
.loginButton {
display: block;
width: 100%;
margin: $space-1 0;
padding: $space-2;
border: none;
border-radius: calc($radius - 2px);
background: $color-primary;
color: $color-primary-contrast;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.signupLink {
display: block;
width: 100%;
padding: $space-1 $space-2;
color: $color-text-muted;
font-size: 0.8rem;
text-align: center;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.menuIcon {
height: 1.1rem;
width: 1.1rem;
flex-shrink: 0;
} }
@keyframes pulse { @keyframes pulse {
+200 -21
View File
@@ -1,37 +1,216 @@
import { Link, useRouter, useRouterState } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { authClient } from '#/lib/auth-client' import { authClient } from '#/lib/auth-client'
import { useBasket } from '#/lib/basket'
import { clearGuestOrderRefs } from '#/lib/guestOrders'
import styles from './header-user.module.scss' import styles from './header-user.module.scss'
const AccountIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<circle cx="12" cy="8" r="4" strokeWidth="1.5" />
<path d="M4 20c0-4 3.5-7 8-7s8 3 8 7" strokeWidth="1.5" />
</svg>
)
const SettingsIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
)
const OrdersIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 3h6l1 3H8l1-3Z" />
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
<path d="M9 11h6" />
</svg>
)
const AdminIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6l7-3Z" />
</svg>
)
const LogOutIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
)
const BetterAuthHeader = () => { const BetterAuthHeader = () => {
const { data: session, isPending } = authClient.useSession() const { data: session, isPending } = authClient.useSession()
const { clear: clearBasket } = useBasket()
const router = useRouter()
const [open, setOpen] = useState(false)
const [imageFailed, setImageFailed] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
useEffect(() => {
setOpen(false)
}, [pathname])
useEffect(() => {
setImageFailed(false)
}, [session?.user?.image])
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
if (isPending) { if (isPending) {
return <div className={`${styles.avatar} ${styles.avatarPulse}`} /> return <div className={`${styles.trigger} ${styles.pulse}`} />
} }
if (session?.user) { return (
return ( <div className={styles.wrapper} ref={wrapperRef}>
<div className={styles.session}> <button
{session.user.image ? ( type="button"
<img src={session.user.image} alt="" className={styles.avatar} /> className={styles.trigger}
onClick={() => setOpen((value) => !value)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={
session?.user ? `Account menu for ${session.user.name}` : 'Account'
}
>
{session?.user ? (
session.user.image && !imageFailed ? (
<img
src={session.user.image}
alt=""
className={styles.avatarImage}
onError={() => setImageFailed(true)}
/>
) : (
<span className={styles.avatarInitial}>
{session.user.name.charAt(0).toUpperCase() || 'U'}
</span>
)
) : ( ) : (
<div className={`${styles.avatar} ${styles.avatarInitial}`}> <AccountIcon />
<span>{session.user.name.charAt(0).toUpperCase() || 'U'}</span>
</div>
)} )}
<button </button>
onClick={() => {
void authClient.signOut()
}}
className={styles.signOut}
>
Sign out
</button>
</div>
)
}
return null {open && (
<div className={styles.menu} role="menu">
{session?.user ? (
<>
<p className={styles.menuLabel}>{session.user.name}</p>
<Link to="/orders" role="menuitem" className={styles.menuButton}>
<OrdersIcon />
Orders
</Link>
<Link to="/account" role="menuitem" className={styles.menuButton}>
<SettingsIcon />
Account
</Link>
{session.user.role === 'admin' && (
<Link
to="/admin"
role="menuitem"
className={styles.menuButton}
>
<AdminIcon />
Administration
</Link>
)}
<button
type="button"
role="menuitem"
className={styles.menuButton}
onClick={() => {
setOpen(false)
clearGuestOrderRefs()
clearBasket()
void authClient.signOut({
fetchOptions: {
onSuccess: () => void router.invalidate(),
},
})
}}
>
<LogOutIcon />
Sign out
</button>
</>
) : (
<>
<p className={styles.menuLabel}>Guest</p>
<Link to="/orders" role="menuitem" className={styles.menuButton}>
<OrdersIcon />
Orders
</Link>
<Link to="/login" role="menuitem" className={styles.loginButton}>
Sign in
</Link>
<Link to="/signup" role="menuitem" className={styles.signupLink}>
New here? Sign up
</Link>
</>
)}
</div>
)}
</div>
)
} }
export default BetterAuthHeader export default BetterAuthHeader
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from '@tanstack/react-router'
// Errors cross the server-function boundary as a plain `Error` carrying only
// `.message` (TanStack Start's ShallowErrorPlugin drops the subclass), so
// requireAdmin's rejection is told apart by message rather than `instanceof`.
// Every /admin/* route loader wraps its data fetch in this so a signed-out
// or non-admin visitor is redirected to sign in instead of crashing.
export const withAdminGuard = async <T>(load: () => Promise<T>): Promise<T> => {
try {
return await load()
} catch (error) {
if (error instanceof Error && error.message === 'Admin access required') {
throw redirect({ to: '/login' })
}
throw error
}
}
+6 -1
View File
@@ -1,3 +1,8 @@
import { createAuthClient } from 'better-auth/react' import { createAuthClient } from 'better-auth/react'
import { inferAdditionalFields } from 'better-auth/client/plugins'
export const authClient = createAuthClient() import type { auth } from '#/lib/auth.ts'
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>()],
})
+49
View File
@@ -2,10 +2,59 @@ import { betterAuth } from 'better-auth'
import { tanstackStartCookies } from 'better-auth/tanstack-start' import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { Pool } from 'pg' import { Pool } from 'pg'
import { db } from '#/db/index.ts'
import { detachCustomerOrders } from '#/domain/ordering.ts'
// Each provider is only registered once its credentials are configured, so
// local dev without OAuth apps set up still works via email/password.
export const auth = betterAuth({ export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }), database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
user: {
additionalFields: {
// `input: false` keeps this out of the public sign-up/update schema so
// a client can't self-assign admin — it's only ever set by a direct DB
// update (see src/db/seed-admin.ts).
role: {
type: 'string',
defaultValue: 'customer',
input: false,
},
},
// No email-sending set up anywhere in this app (no verification flow,
// no password-reset email either), so a changed address is accepted
// immediately rather than left pending on a confirmation link that
// could never be sent.
changeEmail: {
enabled: true,
updateEmailWithoutVerification: true,
},
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
await detachCustomerOrders(db, user.id)
},
},
},
socialProviders: {
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET
? {
apple: {
clientId: process.env.APPLE_CLIENT_ID,
clientSecret: process.env.APPLE_CLIENT_SECRET,
},
}
: {}),
},
plugins: [tanstackStartCookies()], plugins: [tanstackStartCookies()],
}) })
+145
View File
@@ -0,0 +1,145 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import type { ReactNode } from 'react'
const STORAGE_KEY = 'ghost-kitchen-basket'
export type BasketLine = {
menuItemId: number
optionIds: Array<number>
quantity: number
}
type StoredLines = Partial<Record<string, BasketLine>>
// Two lines with the same Menu Item and the same set of selected Options
// are the same line (their quantities combine) — order of selection
// shouldn't matter, so the key sorts the option ids first.
const lineKey = (menuItemId: number, optionIds: Array<number>) =>
`${menuItemId}:${[...optionIds].sort((a, b) => a - b).join(',')}`
const isBasketLine = (value: unknown): value is BasketLine =>
typeof value === 'object' &&
value !== null &&
typeof (value as BasketLine).menuItemId === 'number' &&
Array.isArray((value as BasketLine).optionIds) &&
typeof (value as BasketLine).quantity === 'number'
type BasketContextValue = {
lines: Array<BasketLine>
itemCount: number
add: (menuItemId: number, optionIds?: Array<number>) => void
remove: (menuItemId: number, optionIds?: Array<number>) => void
clear: () => void
}
const BasketContext = createContext<BasketContextValue | null>(null)
const readStoredLines = (): StoredLines => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== 'object' || parsed === null) return {}
const result: StoredLines = {}
for (const [key, value] of Object.entries(parsed)) {
// Earlier versions stored a flat `Record<menuItemId, quantity>` with
// no customization — read a leftover value like that as a plain,
// option-free line rather than dropping it.
if (typeof value === 'number') {
const menuItemId = Number(key)
if (!Number.isFinite(menuItemId) || value <= 0) continue
result[lineKey(menuItemId, [])] = {
menuItemId,
optionIds: [],
quantity: value,
}
} else if (isBasketLine(value)) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
export const BasketProvider = ({ children }: { children: ReactNode }) => {
const [storedLines, setStoredLines] = useState<StoredLines>({})
// Read from localStorage after mount, not during initial render, so the
// server-rendered and first client-rendered HTML match (no localStorage on
// the server) and React doesn't flag a hydration mismatch.
useEffect(() => {
setStoredLines(readStoredLines())
}, [])
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(storedLines))
} catch {
// Unavailable (private mode, quota) — basket still works for this
// page load, it just won't persist across a reload.
}
}, [storedLines])
const add = (menuItemId: number, optionIds: Array<number> = []) => {
const key = lineKey(menuItemId, optionIds)
setStoredLines((prev) => ({
...prev,
[key]: {
menuItemId,
optionIds,
quantity: (prev[key]?.quantity ?? 0) + 1,
},
}))
}
const remove = (menuItemId: number, optionIds: Array<number> = []) => {
const key = lineKey(menuItemId, optionIds)
setStoredLines((prev) => {
const current = prev[key]
if (!current) return prev
if (current.quantity <= 1) {
const next = { ...prev }
delete next[key]
return next
}
return { ...prev, [key]: { ...current, quantity: current.quantity - 1 } }
})
}
const clear = () => setStoredLines({})
const lines = useMemo(
() => Object.values(storedLines).filter((line) => line !== undefined),
[storedLines],
)
const itemCount = useMemo(
() => lines.reduce((sum, line) => sum + line.quantity, 0),
[lines],
)
const value = useMemo(
() => ({ lines, itemCount, add, remove, clear }),
[lines, itemCount],
)
return (
<BasketContext.Provider value={value}>{children}</BasketContext.Provider>
)
}
export const useBasket = () => {
const context = useContext(BasketContext)
if (!context) {
throw new Error('useBasket must be used within a BasketProvider')
}
return context
}
+73
View File
@@ -0,0 +1,73 @@
const priceFormatter = new Intl.NumberFormat(
import.meta.env.VITE_LOCALE || 'de-DE',
{
style: 'currency',
currency: import.meta.env.VITE_CURRENCY || 'EUR',
},
)
// Intl puts the currency symbol wherever the locale's convention says to
// (before the amount for en-US, say) — reassemble the parts ourselves so
// the currency always reads after the amount, regardless of locale.
const formatAmount = (value: number) => {
const parts = priceFormatter.formatToParts(value)
const currency = parts.find((part) => part.type === 'currency')?.value ?? ''
const amount = parts
.filter((part) => part.type !== 'currency')
.map((part) => part.value)
.join('')
.trim()
return `${amount} ${currency}`
}
export const formatPrice = (cents: number) => formatAmount(cents / 100)
export const formatPriceDelta = (cents: number) => {
if (cents === 0) return null
const amount = formatAmount(Math.abs(cents) / 100)
return cents > 0 ? `+${amount}` : `-${amount}`
}
export const formatDuration = (totalSeconds: number) => {
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60))
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) return `${minutes} min`
if (minutes === 0) return `${hours} hr`
return `${hours} hr ${minutes} min`
}
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate())
export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
const target = new Date(now.getTime() + etaSeconds * 1000)
const time = target.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const dayDiff = Math.round(
(startOfDay(target).getTime() - startOfDay(now).getTime()) / 86_400_000,
)
if (dayDiff === 0) return `today, ${time}`
if (dayDiff === 1) return `tomorrow, ${time}`
return `${target.toLocaleDateString([], { weekday: 'long' })}, ${time}`
}
const ABSOLUTE_TIME_THRESHOLD_SECONDS = 3 * 60 * 60
export const formatEtaParts = (etaSeconds: number, now = new Date()) =>
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
: { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
export const formatCountdown = (remainingMs: number) => {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
+39
View File
@@ -0,0 +1,39 @@
// A guest's name/contact, remembered locally so checkout doesn't ask again
// on their next order from this browser.
const STORAGE_KEY = 'ghost-kitchen-guest-identity'
export type GuestIdentity = { name: string; contact: string }
const isGuestIdentity = (value: unknown): value is GuestIdentity =>
typeof value === 'object' &&
value !== null &&
typeof (value as GuestIdentity).name === 'string' &&
typeof (value as GuestIdentity).contact === 'string'
export const getGuestIdentity = (): GuestIdentity | null => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
return isGuestIdentity(parsed) ? parsed : null
} catch {
return null
}
}
export const saveGuestIdentity = (identity: GuestIdentity) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identity))
} catch {
// Unavailable (private mode, quota) — checkout still works, it just
// asks again next time.
}
}
export const clearGuestIdentity = () => {
try {
window.localStorage.removeItem(STORAGE_KEY)
} catch {
// Unavailable — nothing to clear.
}
}
+46
View File
@@ -0,0 +1,46 @@
// Guests have no account to list Orders under, so their Order history is
// tracked client-side instead: each placed guest Order's id + access token
// is remembered here so `/orders` can look them back up without a sign-in.
const STORAGE_KEY = 'ghost-kitchen-guest-orders'
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
const isGuestOrderRef = (value: unknown): value is GuestOrderRef =>
typeof value === 'object' &&
value !== null &&
typeof (value as GuestOrderRef).orderId === 'number' &&
typeof (value as GuestOrderRef).guestAccessToken === 'string'
export const getGuestOrderRefs = (): Array<GuestOrderRef> => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter(isGuestOrderRef) : []
} catch {
return []
}
}
export const addGuestOrderRef = (ref: GuestOrderRef) => {
try {
const existing = getGuestOrderRefs().filter(
(stored) => stored.orderId !== ref.orderId,
)
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify([ref, ...existing]),
)
} catch {
// Unavailable (private mode, quota) — the order still works via its
// own link, it just won't show up in the local Orders list.
}
}
export const clearGuestOrderRefs = () => {
try {
window.localStorage.removeItem(STORAGE_KEY)
} catch {
// Unavailable — nothing to clear.
}
}
+6 -1
View File
@@ -6,6 +6,11 @@ import type Stripe from 'stripe'
// exist here — build one explicitly to load Stripe's CJS build lazily. // exist here — build one explicitly to load Stripe's CJS build lazily.
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
// Stripe wants the ISO 4217 code lowercased. Shares VITE_CURRENCY with the
// display-side formatter in lib/format.ts so the charged and displayed
// currency can't drift apart.
const currency = (import.meta.env.VITE_CURRENCY || 'EUR').toLowerCase()
export type Charge = { export type Charge = {
chargeId: string chargeId: string
amountCents: number amountCents: number
@@ -64,7 +69,7 @@ export const createStripePaymentProvider = (
charge: async (amountCents) => { charge: async (amountCents) => {
const intent = await stripe.paymentIntents.create({ const intent = await stripe.paymentIntents.create({
amount: amountCents, amount: amountCents,
currency: 'usd', currency,
confirm: true, confirm: true,
payment_method: 'pm_card_visa', payment_method: 'pm_card_visa',
automatic_payment_methods: { automatic_payment_methods: {
+334 -3
View File
@@ -10,7 +10,22 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as AccountRouteImport } from './routes/account'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as BasketRouteImport } from './routes/basket'
import { Route as HelpRouteImport } from './routes/help'
import { Route as LoginRouteImport } from './routes/login'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminKitchenRouteImport } from './routes/admin/kitchen'
import { Route as ItemsMenuItemIdRouteImport } from './routes/items/$menuItemId'
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId' import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
import { Route as AdminMenuIndexRouteImport } from './routes/admin/menu/index'
import { Route as AdminMenuMenuItemIdRouteImport } from './routes/admin/menu/$menuItemId'
import { Route as AdminMenuNewRouteImport } from './routes/admin/menu/new'
import { Route as AdminOrdersIndexRouteImport } from './routes/admin/orders/index'
import { Route as AdminOrdersOrderIdRouteImport } from './routes/admin/orders/$orderId'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
@@ -18,11 +33,86 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AccountRoute = AccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => rootRouteImport,
} as any)
const BasketRoute = BasketRouteImport.update({
id: '/basket',
path: '/basket',
getParentRoute: () => rootRouteImport,
} as any)
const HelpRoute = HelpRouteImport.update({
id: '/help',
path: '/help',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const SignupRoute = SignupRouteImport.update({
id: '/signup',
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
const AdminIndexRoute = AdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AdminRoute,
} as any)
const AdminKitchenRoute = AdminKitchenRouteImport.update({
id: '/kitchen',
path: '/kitchen',
getParentRoute: () => AdminRoute,
} as any)
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
id: '/items/$menuItemId',
path: '/items/$menuItemId',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersIndexRoute = OrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({ const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
id: '/orders/$orderId', id: '/orders/$orderId',
path: '/orders/$orderId', path: '/orders/$orderId',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminMenuIndexRoute = AdminMenuIndexRouteImport.update({
id: '/menu/',
path: '/menu/',
getParentRoute: () => AdminRoute,
} as any)
const AdminMenuMenuItemIdRoute = AdminMenuMenuItemIdRouteImport.update({
id: '/menu/$menuItemId',
path: '/menu/$menuItemId',
getParentRoute: () => AdminRoute,
} as any)
const AdminMenuNewRoute = AdminMenuNewRouteImport.update({
id: '/menu/new',
path: '/menu/new',
getParentRoute: () => AdminRoute,
} as any)
const AdminOrdersIndexRoute = AdminOrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
getParentRoute: () => AdminRoute,
} as any)
const AdminOrdersOrderIdRoute = AdminOrdersOrderIdRouteImport.update({
id: '/orders/$orderId',
path: '/orders/$orderId',
getParentRoute: () => AdminRoute,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$', id: '/api/auth/$',
path: '/api/auth/$', path: '/api/auth/$',
@@ -31,31 +121,137 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/account': typeof AccountRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/admin/': typeof AdminIndexRoute
'/orders/': typeof OrdersIndexRoute
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
'/admin/menu/new': typeof AdminMenuNewRoute
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/admin/menu/': typeof AdminMenuIndexRoute
'/admin/orders/': typeof AdminOrdersIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/account': typeof AccountRoute
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/admin': typeof AdminIndexRoute
'/orders': typeof OrdersIndexRoute
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
'/admin/menu/new': typeof AdminMenuNewRoute
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/admin/menu': typeof AdminMenuIndexRoute
'/admin/orders': typeof AdminOrdersIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/account': typeof AccountRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/admin/': typeof AdminIndexRoute
'/orders/': typeof OrdersIndexRoute
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
'/admin/menu/new': typeof AdminMenuNewRoute
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/admin/menu/': typeof AdminMenuIndexRoute
'/admin/orders/': typeof AdminOrdersIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/orders/$orderId' | '/api/auth/$' fullPaths:
| '/'
| '/account'
| '/admin'
| '/basket'
| '/help'
| '/login'
| '/signup'
| '/admin/kitchen'
| '/items/$menuItemId'
| '/orders/$orderId'
| '/admin/'
| '/orders/'
| '/admin/menu/$menuItemId'
| '/admin/menu/new'
| '/admin/orders/$orderId'
| '/api/auth/$'
| '/admin/menu/'
| '/admin/orders/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/orders/$orderId' | '/api/auth/$' to:
id: '__root__' | '/' | '/orders/$orderId' | '/api/auth/$' | '/'
| '/account'
| '/basket'
| '/help'
| '/login'
| '/signup'
| '/admin/kitchen'
| '/items/$menuItemId'
| '/orders/$orderId'
| '/admin'
| '/orders'
| '/admin/menu/$menuItemId'
| '/admin/menu/new'
| '/admin/orders/$orderId'
| '/api/auth/$'
| '/admin/menu'
| '/admin/orders'
id:
| '__root__'
| '/'
| '/account'
| '/admin'
| '/basket'
| '/help'
| '/login'
| '/signup'
| '/admin/kitchen'
| '/items/$menuItemId'
| '/orders/$orderId'
| '/admin/'
| '/orders/'
| '/admin/menu/$menuItemId'
| '/admin/menu/new'
| '/admin/orders/$orderId'
| '/api/auth/$'
| '/admin/menu/'
| '/admin/orders/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AccountRoute: typeof AccountRoute
AdminRoute: typeof AdminRouteWithChildren
BasketRoute: typeof BasketRoute
HelpRoute: typeof HelpRoute
LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
OrdersOrderIdRoute: typeof OrdersOrderIdRoute OrdersOrderIdRoute: typeof OrdersOrderIdRoute
OrdersIndexRoute: typeof OrdersIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
} }
@@ -68,6 +264,76 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/account': {
id: '/account'
path: '/account'
fullPath: '/account'
preLoaderRoute: typeof AccountRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/basket': {
id: '/basket'
path: '/basket'
fullPath: '/basket'
preLoaderRoute: typeof BasketRouteImport
parentRoute: typeof rootRouteImport
}
'/help': {
id: '/help'
path: '/help'
fullPath: '/help'
preLoaderRoute: typeof HelpRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/signup': {
id: '/signup'
path: '/signup'
fullPath: '/signup'
preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/': {
id: '/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof AdminIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/kitchen': {
id: '/admin/kitchen'
path: '/kitchen'
fullPath: '/admin/kitchen'
preLoaderRoute: typeof AdminKitchenRouteImport
parentRoute: typeof AdminRoute
}
'/items/$menuItemId': {
id: '/items/$menuItemId'
path: '/items/$menuItemId'
fullPath: '/items/$menuItemId'
preLoaderRoute: typeof ItemsMenuItemIdRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/': {
id: '/orders/'
path: '/orders'
fullPath: '/orders/'
preLoaderRoute: typeof OrdersIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/$orderId': { '/orders/$orderId': {
id: '/orders/$orderId' id: '/orders/$orderId'
path: '/orders/$orderId' path: '/orders/$orderId'
@@ -75,6 +341,41 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OrdersOrderIdRouteImport preLoaderRoute: typeof OrdersOrderIdRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/admin/menu/': {
id: '/admin/menu/'
path: '/menu'
fullPath: '/admin/menu/'
preLoaderRoute: typeof AdminMenuIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/menu/$menuItemId': {
id: '/admin/menu/$menuItemId'
path: '/menu/$menuItemId'
fullPath: '/admin/menu/$menuItemId'
preLoaderRoute: typeof AdminMenuMenuItemIdRouteImport
parentRoute: typeof AdminRoute
}
'/admin/menu/new': {
id: '/admin/menu/new'
path: '/menu/new'
fullPath: '/admin/menu/new'
preLoaderRoute: typeof AdminMenuNewRouteImport
parentRoute: typeof AdminRoute
}
'/admin/orders/': {
id: '/admin/orders/'
path: '/orders'
fullPath: '/admin/orders/'
preLoaderRoute: typeof AdminOrdersIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/orders/$orderId': {
id: '/admin/orders/$orderId'
path: '/orders/$orderId'
fullPath: '/admin/orders/$orderId'
preLoaderRoute: typeof AdminOrdersOrderIdRouteImport
parentRoute: typeof AdminRoute
}
'/api/auth/$': { '/api/auth/$': {
id: '/api/auth/$' id: '/api/auth/$'
path: '/api/auth/$' path: '/api/auth/$'
@@ -85,9 +386,39 @@ declare module '@tanstack/react-router' {
} }
} }
interface AdminRouteChildren {
AdminKitchenRoute: typeof AdminKitchenRoute
AdminIndexRoute: typeof AdminIndexRoute
AdminMenuMenuItemIdRoute: typeof AdminMenuMenuItemIdRoute
AdminMenuNewRoute: typeof AdminMenuNewRoute
AdminOrdersOrderIdRoute: typeof AdminOrdersOrderIdRoute
AdminMenuIndexRoute: typeof AdminMenuIndexRoute
AdminOrdersIndexRoute: typeof AdminOrdersIndexRoute
}
const AdminRouteChildren: AdminRouteChildren = {
AdminKitchenRoute: AdminKitchenRoute,
AdminIndexRoute: AdminIndexRoute,
AdminMenuMenuItemIdRoute: AdminMenuMenuItemIdRoute,
AdminMenuNewRoute: AdminMenuNewRoute,
AdminOrdersOrderIdRoute: AdminOrdersOrderIdRoute,
AdminMenuIndexRoute: AdminMenuIndexRoute,
AdminOrdersIndexRoute: AdminOrdersIndexRoute,
}
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AccountRoute: AccountRoute,
AdminRoute: AdminRouteWithChildren,
BasketRoute: BasketRoute,
HelpRoute: HelpRoute,
LoginRoute: LoginRoute,
SignupRoute: SignupRoute,
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
OrdersOrderIdRoute: OrdersOrderIdRoute, OrdersOrderIdRoute: OrdersOrderIdRoute,
OrdersIndexRoute: OrdersIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
+1
View File
@@ -7,6 +7,7 @@ export const getRouter = () => {
scrollRestoration: true, scrollRestoration: true,
defaultPreload: 'intent', defaultPreload: 'intent',
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
defaultViewTransition: true,
}) })
return router return router
+39
View File
@@ -0,0 +1,39 @@
@use '../styles/variables' as *;
.topBar {
position: sticky;
top: 0;
// Explicit stacking context, ranked above any page content: setting
// `view-transition-name` on an element (here and on OrderStatusView's
// order card) implicitly makes it its own stacking context too — without
// this, whichever one comes later in the DOM wins ties on z-index:0, so
// the account menu dropdown was rendering underneath the order card on
// the order status page.
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-3;
background: color-mix(in srgb, $color-background 30%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid color-mix(in srgb, $color-border 30%, transparent);
}
.content {
flex: 1 0 auto;
min-width: 0;
}
.brand {
font-size: 1.1rem;
font-weight: 700;
color: $color-text;
text-decoration: none;
}
.actions {
display: flex;
align-items: center;
gap: $space-2;
}
+59 -25
View File
@@ -1,33 +1,55 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import {
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' HeadContent,
import { TanStackDevtools } from '@tanstack/react-devtools' Link,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { BasketLink } from '#/components/basket/BasketLink.tsx'
import { Footer } from '#/components/ui/Footer.tsx'
import { Logo } from '#/components/ui/Logo.tsx'
import { NotFound } from '#/components/ui/NotFound.tsx'
import BetterAuthHeader from '#/integrations/better-auth/header-user.tsx'
import { BasketProvider } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
import '../styles/globals.scss' import '../styles/globals.scss'
import styles from './__root.module.scss'
const RootDocument = ({ children }: { children: React.ReactNode }) => ( const RootDocument = ({ children }: { children: React.ReactNode }) => {
<html lang="en"> const { brand } = Route.useLoaderData()
<head>
<HeadContent /> return (
</head> <html lang="en">
<body> <head>
{children} <HeadContent />
<TanStackDevtools </head>
config={{ <body>
position: 'bottom-right', <BasketProvider>
}} <div
plugins={[ className={styles.topBar}
{ style={{ viewTransitionName: 'site-header' }}
name: 'Tanstack Router', >
render: <TanStackRouterDevtoolsPanel />, <Link to="/" className={styles.brand}>
}, <Logo name={brand.name} />
]} </Link>
/> <div className={styles.actions}>
<Scripts /> <BasketLink />
</body> <BetterAuthHeader />
</html> </div>
) </div>
<div className={styles.content}>{children}</div>
<Footer brandName={brand.name} />
</BasketProvider>
<Scripts />
</body>
</html>
)
}
export const Route = createRootRoute({ export const Route = createRootRoute({
loader: async () => ({ brand: await getBrand() }),
notFoundComponent: NotFound,
head: () => ({ head: () => ({
meta: [ meta: [
{ {
@@ -41,6 +63,18 @@ export const Route = createRootRoute({
title: 'Ghost Kitchen', title: 'Ghost Kitchen',
}, },
], ],
links: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap',
},
],
}), }),
shellComponent: RootDocument, shellComponent: RootDocument,
}) })
+143
View File
@@ -0,0 +1,143 @@
@use '../styles/variables' as *;
.page {
max-width: 480px;
margin: 0 auto;
padding: $space-5 $space-3;
display: flex;
flex-direction: column;
gap: $space-5;
}
.heading {
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.sectionHeading {
margin: 0;
font-size: 1.05rem;
}
.form {
display: flex;
flex-direction: column;
gap: $space-2;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
background: $color-background;
}
}
.submitButton {
align-self: flex-start;
padding: $space-2 $space-4;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
.success {
color: $color-success;
font-size: 0.85rem;
margin: 0;
}
.dangerZone {
border-color: $color-danger;
}
.dangerText {
margin: 0;
color: $color-text-muted;
font-size: 0.9rem;
}
.dangerActions {
display: flex;
align-items: center;
gap: $space-2;
}
.deleteButton {
align-self: flex-start;
padding: $space-2 $space-4;
border: 1px solid $color-danger;
border-radius: $radius;
background: $color-background;
color: $color-danger;
cursor: pointer;
font-weight: 600;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
background: $color-danger;
color: $color-primary-contrast;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cancelButton {
padding: $space-2 $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-text;
cursor: pointer;
&:hover:not(:disabled) {
border-color: $color-text;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
+238
View File
@@ -0,0 +1,238 @@
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { authClient } from '#/lib/auth-client.ts'
import { getCurrentUser } from '#/server/auth.ts'
import styles from './account.module.scss'
const AccountPage = () => {
const user = Route.useLoaderData()
const navigate = useNavigate()
const [name, setName] = useState(user.name)
const [email, setEmail] = useState(user.email)
const [savedName, setSavedName] = useState(user.name)
const [savedEmail, setSavedEmail] = useState(user.email)
const [profileSubmitting, setProfileSubmitting] = useState(false)
const [profileError, setProfileError] = useState<string | null>(null)
const [profileSaved, setProfileSaved] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [passwordSubmitting, setPasswordSubmitting] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [passwordSaved, setPasswordSaved] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deletePassword, setDeletePassword] = useState('')
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const submitProfile = async (event: FormEvent) => {
event.preventDefault()
setProfileSubmitting(true)
setProfileError(null)
setProfileSaved(false)
try {
if (name !== savedName) {
const { error } = await authClient.updateUser({ name })
if (error) throw new Error(error.message ?? 'Could not update your name.')
setSavedName(name)
}
if (email !== savedEmail) {
const { error } = await authClient.changeEmail({ newEmail: email })
if (error) throw new Error(error.message ?? 'Could not update your email.')
setSavedEmail(email)
}
setProfileSaved(true)
} catch (err) {
setProfileError(
err instanceof Error ? err.message : 'Something went wrong.',
)
} finally {
setProfileSubmitting(false)
}
}
const submitPassword = async (event: FormEvent) => {
event.preventDefault()
setPasswordSubmitting(true)
setPasswordError(null)
setPasswordSaved(false)
try {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
})
if (error) throw new Error(error.message ?? 'Could not change your password.')
setCurrentPassword('')
setNewPassword('')
setPasswordSaved(true)
} catch (err) {
setPasswordError(
err instanceof Error ? err.message : 'Something went wrong.',
)
} finally {
setPasswordSubmitting(false)
}
}
const deleteAccount = async () => {
setDeleting(true)
setDeleteError(null)
try {
const { error } = await authClient.deleteUser(
deletePassword ? { password: deletePassword } : {},
)
if (error) throw new Error(error.message ?? 'Could not delete your account.')
await navigate({ to: '/' })
} catch (err) {
setDeleteError(
err instanceof Error ? err.message : 'Something went wrong.',
)
setDeleting(false)
}
}
return (
<main className={styles.page}>
<h1 className={styles.heading}>Account</h1>
<section className={styles.section}>
<h2 className={styles.sectionHeading}>Profile</h2>
<form className={styles.form} onSubmit={submitProfile}>
<label className={styles.field}>
Name
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={profileSubmitting}
>
{profileSubmitting ? 'Saving…' : 'Save changes'}
</button>
{profileError && <p className={styles.error}>{profileError}</p>}
{profileSaved && <p className={styles.success}>Saved.</p>}
</form>
</section>
<section className={styles.section}>
<h2 className={styles.sectionHeading}>Password</h2>
<form className={styles.form} onSubmit={submitPassword}>
<label className={styles.field}>
Current password
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
required
/>
</label>
<label className={styles.field}>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
minLength={8}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={passwordSubmitting}
>
{passwordSubmitting ? 'Changing…' : 'Change password'}
</button>
{passwordError && <p className={styles.error}>{passwordError}</p>}
{passwordSaved && <p className={styles.success}>Password changed.</p>}
</form>
</section>
<section className={`${styles.section} ${styles.dangerZone}`}>
<h2 className={styles.sectionHeading}>Delete account</h2>
<p className={styles.dangerText}>
This permanently deletes your account. Your past orders stay on
record but are no longer linked to you. This can’t be undone.
</p>
{confirmingDelete ? (
<div className={styles.form}>
<label className={styles.field}>
Confirm your password
<input
type="password"
value={deletePassword}
onChange={(event) => setDeletePassword(event.target.value)}
/>
</label>
<div className={styles.dangerActions}>
<button
type="button"
className={styles.deleteButton}
disabled={deleting}
onClick={() => void deleteAccount()}
>
{deleting ? 'Deleting…' : 'Permanently delete my account'}
</button>
<button
type="button"
className={styles.cancelButton}
disabled={deleting}
onClick={() => {
setConfirmingDelete(false)
setDeletePassword('')
setDeleteError(null)
}}
>
Cancel
</button>
</div>
{deleteError && <p className={styles.error}>{deleteError}</p>}
</div>
) : (
<button
type="button"
className={styles.deleteButton}
onClick={() => setConfirmingDelete(true)}
>
Delete account
</button>
)}
</section>
</main>
)
}
export const Route = createFileRoute('/account')({
loader: async () => {
try {
return await getCurrentUser()
} catch (error) {
if (error instanceof Error && error.message === 'Not signed in') {
throw redirect({ to: '/login' })
}
throw error
}
},
component: AccountPage,
})
+78
View File
@@ -0,0 +1,78 @@
@use '../styles/variables' as *;
.layout {
display: flex;
align-items: flex-start;
gap: $space-4;
max-width: 1100px;
margin: 0 auto;
padding: $space-4 $space-3;
@media (max-width: $breakpoint-tablet) {
// align-items controls the cross axis, which flips from height to
// width once this switches to a column — flex-start (correct for
// desktop, so the sidebar and content don't stretch to match heights)
// would otherwise shrink-to-fit each child's own content width instead
// of stretching it to the viewport, letting long content overflow.
flex-direction: column;
align-items: stretch;
}
}
.sidebar {
flex: 0 0 200px;
display: flex;
flex-direction: column;
gap: $space-1;
position: sticky;
top: $space-4;
@media (max-width: $breakpoint-tablet) {
// `.layout` stacks to a column below, so this flex-basis would
// otherwise fix the sidebar's *height* at 200px instead of its width
// — reset it and stop the row of links from stretching to fill that.
flex: 0 0 auto;
align-items: center;
position: static;
flex-direction: row;
width: 100%;
overflow-x: auto;
}
}
.sidebarTitle {
margin: 0 0 $space-2;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $color-text-muted;
@media (max-width: $breakpoint-tablet) {
display: none;
}
}
.navLink {
padding: $space-2 $space-3;
border-radius: $radius;
color: $color-text;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
}
.navLinkActive {
background: $color-surface;
color: $color-primary;
}
.content {
flex: 1;
min-width: 0;
}
+46
View File
@@ -0,0 +1,46 @@
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { requireAdminSession } from '#/server/auth.ts'
import styles from './admin.module.scss'
const NAV_ITEMS = [
{ to: '/admin/orders', label: 'Orders' },
{ to: '/admin/menu', label: 'Menu' },
{ to: '/admin/kitchen', label: 'Kitchen' },
] as const
const AdminLayout = () => (
<div className={styles.layout}>
<nav
className={styles.sidebar}
style={{ viewTransitionName: 'admin-sidebar' }}
>
<p className={styles.sidebarTitle}>Administration</p>
{NAV_ITEMS.map((item) => (
<Link
key={item.to}
to={item.to}
className={styles.navLink}
activeProps={{ className: styles.navLinkActive }}
// The sidebar and overall layout never change between /admin/*
// pages — a view transition's root crossfade has nothing useful
// to animate here and just makes the static sidebar look like
// it's moving, so navigation within the admin panel skips it.
viewTransition={false}
>
{item.label}
</Link>
))}
</nav>
<div className={styles.content}>
<Outlet />
</div>
</div>
)
export const Route = createFileRoute('/admin')({
loader: () => withAdminGuard(() => requireAdminSession()),
component: AdminLayout,
})
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
// Orders is the only admin section today — this is the stable entry point
// the account menu's "Administration" link points at, so adding more
// sections later doesn't mean touching the menu again. /admin/orders itself
// still enforces the admin check.
export const Route = createFileRoute('/admin/')({
loader: () => {
throw redirect({ to: '/admin/orders' })
},
})
+108
View File
@@ -0,0 +1,108 @@
@use '../../styles/variables' as *;
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
margin-bottom: $space-4;
}
.heading {
margin: 0;
}
.refreshButton {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-text;
cursor: pointer;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
}
.tagSection {
margin-bottom: $space-4;
}
.tagHeader {
display: flex;
align-items: baseline;
gap: $space-2;
margin-bottom: $space-2;
}
.tagName {
margin: 0;
font-size: 1rem;
font-weight: 600;
text-transform: capitalize;
}
.tagQueue {
font-size: 0.85rem;
color: $color-text-muted;
}
.machines {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-2;
}
.machine {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.machineInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.machineName {
font-weight: 600;
}
.machineCurrent {
font-size: 0.85rem;
color: $color-text-muted;
}
.status {
flex: 0 0 auto;
padding: 2px $space-2;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.statusIdle {
background: color-mix(in srgb, $color-success 12%, transparent);
color: $color-success;
}
.statusBusy {
background: color-mix(in srgb, $color-primary 12%, transparent);
color: $color-primary;
}
.empty {
color: $color-text-muted;
}
+98
View File
@@ -0,0 +1,98 @@
import { createFileRoute, useRouter } from '@tanstack/react-router'
import type { KitchenStatus } from '#/domain/kitchen.ts'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { getKitchenStatus } from '#/server/kitchen.ts'
import styles from './kitchen.module.scss'
const formatStartedAt = (startedAt: Date | null) =>
startedAt
? startedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: 'just now'
const groupByTag = (machines: KitchenStatus['machines']) => {
const byTag = new Map<string, KitchenStatus['machines']>()
for (const machine of machines) {
byTag.set(machine.tagName, [...(byTag.get(machine.tagName) ?? []), machine])
}
return byTag
}
const KitchenRoute = () => {
const { machines, queues } = Route.useLoaderData()
const router = useRouter()
const machinesByTag = groupByTag(machines)
const queueByTag = new Map(queues.map((queue) => [queue.tagName, queue]))
return (
<section>
<div className={styles.header}>
<h1 className={styles.heading}>Kitchen</h1>
<button
type="button"
className={styles.refreshButton}
onClick={() => void router.invalidate()}
>
Refresh
</button>
</div>
{machines.length === 0 ? (
<p className={styles.empty}>No machines are configured.</p>
) : (
[...machinesByTag.entries()].map(([tagName, tagMachines]) => {
const queue = queueByTag.get(tagName)
return (
<div key={tagName} className={styles.tagSection}>
<div className={styles.tagHeader}>
<h2 className={styles.tagName}>{tagName}</h2>
{queue && (
<span className={styles.tagQueue}>
{queue.idleMachineCount} idle · {queue.busyMachineCount}{' '}
busy
{queue.queuedStepCount > 0 &&
` · ${queue.queuedStepCount} waiting`}
</span>
)}
</div>
<ul className={styles.machines}>
{tagMachines.map((machine) => (
<li key={machine.id} className={styles.machine}>
<div className={styles.machineInfo}>
<span className={styles.machineName}>
{machine.name}
</span>
{machine.current && (
<span className={styles.machineCurrent}>
{machine.current.menuItemName} ·{' '}
{machine.current.stepName} · Order #
{machine.current.orderId} · since{' '}
{formatStartedAt(machine.current.startedAt)}
</span>
)}
</div>
<span
className={`${styles.status} ${
machine.status === 'busy'
? styles.statusBusy
: styles.statusIdle
}`}
>
{machine.status}
</span>
</li>
))}
</ul>
</div>
)
})
)}
</section>
)
}
export const Route = createFileRoute('/admin/kitchen')({
loader: () => withAdminGuard(() => getKitchenStatus()),
component: KitchenRoute,
})
+47
View File
@@ -0,0 +1,47 @@
import { createFileRoute, notFound, useNavigate } from '@tanstack/react-router'
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import {
getAdminMenu,
getAllergenOptions,
getMachineTagOptions,
updateMenuItem,
} from '#/server/menu-admin.ts'
const EditMenuItemRoute = () => {
const { item, machineTags, allergenOptions } = Route.useLoaderData()
const navigate = useNavigate()
return (
<section>
<h1>Edit {item.name}</h1>
<MenuItemForm
machineTags={machineTags}
allergenOptions={allergenOptions}
initialItem={item}
submitLabel="Save changes"
onSubmit={async (input) => {
await updateMenuItem({ data: { menuItemId: item.id, ...input } })
await navigate({ to: '/admin/menu', viewTransition: false })
}}
/>
</section>
)
}
export const Route = createFileRoute('/admin/menu/$menuItemId')({
loader: async ({ params }) => {
const [items, machineTags, allergenOptions] = await withAdminGuard(() =>
Promise.all([
getAdminMenu(),
getMachineTagOptions(),
getAllergenOptions(),
]),
)
const item = items.find((i) => i.id === Number(params.menuItemId))
if (!item) throw notFound()
return { item, machineTags, allergenOptions }
},
component: EditMenuItemRoute,
})
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { AdminMenuListView } from '#/components/admin/AdminMenuListView.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { deleteMenuItem, getAdminMenu } from '#/server/menu-admin.ts'
const AdminMenuRoute = () => {
const items = Route.useLoaderData()
const router = useRouter()
return (
<AdminMenuListView
items={items}
onDelete={async (menuItemId) => {
await deleteMenuItem({ data: { menuItemId } })
await router.invalidate()
}}
/>
)
}
export const Route = createFileRoute('/admin/menu/')({
loader: () => withAdminGuard(() => getAdminMenu()),
component: AdminMenuRoute,
})
+38
View File
@@ -0,0 +1,38 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import {
createMenuItem,
getAllergenOptions,
getMachineTagOptions,
} from '#/server/menu-admin.ts'
const NewMenuItemRoute = () => {
const { machineTags, allergenOptions } = Route.useLoaderData()
const navigate = useNavigate()
return (
<section>
<h1>Add menu item</h1>
<MenuItemForm
machineTags={machineTags}
allergenOptions={allergenOptions}
submitLabel="Create item"
onSubmit={async (input) => {
await createMenuItem({ data: input })
await navigate({ to: '/admin/menu', viewTransition: false })
}}
/>
</section>
)
}
export const Route = createFileRoute('/admin/menu/new')({
loader: () =>
withAdminGuard(async () => ({
machineTags: await getMachineTagOptions(),
allergenOptions: await getAllergenOptions(),
})),
component: NewMenuItemRoute,
})

Some files were not shown because too many files have changed in this diff Show More