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>
This commit is contained in:
2026-09-10 21:00:45 +02:00
co-authored by Claude Sonnet 5
parent e98b0d86d2
commit d66e09f515
38 changed files with 2099 additions and 229 deletions
+6
View File
@@ -2,6 +2,12 @@ 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=
+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;
+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": {}
}
}
+7
View File
@@ -29,6 +29,13 @@
"when": 1789028563859,
"tag": "0003_soft_psynapse",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1789036823859,
"tag": "0004_misty_hedge_knight",
"breakpoints": true
}
]
}
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

+1 -1
View File
@@ -26,7 +26,7 @@
border: 1px solid $color-border;
&:hover {
background: $color-surface;
background: $color-border;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import styles from './BasketLink.module.scss'
const MotionLink = motion.create(Link)
const BasketIcon = () => (
export const BasketIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
@@ -31,6 +31,11 @@
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
+1 -1
View File
@@ -19,7 +19,7 @@ type Customer =
type PlacedOrder = { id: number; guestAccessToken: string | null }
type CheckoutSectionProps = {
items: Array<{ menuItemId: number }>
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
disabled: boolean
onPlaced: (order: PlacedOrder) => void
}
@@ -0,0 +1,82 @@
@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 {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-background;
cursor: pointer;
transition: border-color 0.15s ease;
&: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,98 @@
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}>
<span className={styles.price}>{formatPrice(unitPriceCents)}</span>
<div className={styles.stepper}>
<button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id, checkedOptionIds)}
disabled={quantity === 0}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<AnimatedNumber value={quantity} className={styles.count} />
<button
type="button"
className={styles.stepperButton}
onClick={() => onAdd(item.id, checkedOptionIds)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
@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;
}
@@ -0,0 +1,54 @@
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>
</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>
)
}
+36 -4
View File
@@ -10,16 +10,42 @@
}
.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;
}
.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 {
flex: 1;
}
@@ -27,6 +53,7 @@
.name {
margin: 0 0 $space-1;
font-size: 1.1rem;
transition: color 0.15s ease;
}
.description {
@@ -56,6 +83,11 @@
cursor: pointer;
align-self: center;
justify-self: center;
transition: border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
}
&:disabled {
opacity: 0.4;
+44 -18
View File
@@ -1,38 +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 { BasketLine } from '#/lib/basket.tsx'
import { formatPrice } from '#/lib/format.ts'
import styles from './MenuList.module.scss'
type MenuListProps = {
menu: Array<MenuItemWithRecipe>
quantities: Record<number, number>
onAdd: (menuItemId: number) => void
onRemove: (menuItemId: number) => void
lines: Array<BasketLine>
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
}
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatDuration = (seconds: number) => {
export const formatDuration = (seconds: number) => {
const minutes = Math.round(seconds / 60)
return minutes <= 1 ? '~1 min' : `~${minutes} min`
return minutes <= 1 ? '1 min' : `${minutes} min`
}
export const MenuList = ({
menu,
quantities,
onAdd,
onRemove,
}: MenuListProps) => (
export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
<ul className={styles.list}>
{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(
(sum, step) => sum + step.estimatedDurationSeconds,
0,
)
return (
<li key={item.id} className={styles.item}>
const info = (
<>
<MenuItemThumbnail
name={item.name}
style={{ viewTransitionName: `menu-item-${item.id}` }}
/>
<div className={styles.info}>
<h3 className={styles.name}>{item.name}</h3>
{item.description && (
@@ -42,17 +47,37 @@ export const MenuList = ({
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p>
</div>
</>
)
return (
<li key={item.id} className={styles.item}>
<div className={styles.row}>
{hasOptions ? (
<Link
to="/items/$menuItemId"
params={{ menuItemId: String(item.id) }}
className={styles.main}
>
{info}
</Link>
) : (
<div className={styles.main}>{info}</div>
)}
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => onRemove(item.id)}
disabled={quantity === 0}
disabled={(defaultLine?.quantity ?? 0) === 0}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<AnimatedNumber value={quantity} className={styles.count} />
<AnimatedNumber
value={defaultLine?.quantity ?? 0}
className={styles.count}
/>
<button
type="button"
className={styles.stepperButton}
@@ -62,6 +87,7 @@ export const MenuList = ({
+
</button>
</div>
</div>
</li>
)
})}
@@ -1,5 +1,13 @@
@use '../../styles/variables' as *;
@position-try --cancel-hint-flip {
position-area: bottom;
--hint-arrow-top: auto;
--hint-arrow-bottom: 100%;
--hint-arrow-color-top: transparent;
--hint-arrow-color-bottom: #{$color-text};
}
.page {
max-width: 720px;
margin: 0 auto;
@@ -36,11 +44,17 @@
}
.orderStatus {
margin: 0 0 $space-4;
margin: 0 0 $space-1;
color: $color-text-muted;
text-transform: capitalize;
}
.orderDate {
margin: 0 0 $space-4;
color: $color-text-muted;
font-size: 0.85rem;
}
.error {
color: $color-danger;
}
@@ -73,6 +87,12 @@
color: $color-text-muted;
}
.itemOptions {
margin: 0 0 $space-2;
font-size: 0.85rem;
color: $color-text-muted;
}
.timeline {
margin: 0 0 $space-2;
padding: 0;
@@ -194,6 +214,7 @@
.actions {
display: flex;
flex-wrap: wrap;
gap: $space-2;
align-items: center;
@@ -206,6 +227,11 @@
color: $color-text;
text-decoration: none;
cursor: pointer;
transition: border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
}
&:disabled {
opacity: 0.5;
@@ -213,6 +239,66 @@
}
}
.infoButton {
display: inline-flex;
align-items: center;
flex-shrink: 0;
gap: $space-1;
padding: $space-1 $space-2;
color: $color-text-muted;
font-size: 0.85rem;
white-space: nowrap;
&:hover:not(:disabled) {
color: $color-primary;
border-color: $color-primary;
}
}
.infoIcon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.cancelHint {
position: fixed;
position-area: top;
position-try-fallbacks: --cancel-hint-flip;
inset: auto;
overflow: visible;
height: auto;
margin: $space-2;
padding: $space-2 $space-3;
width: max-content;
max-width: 260px;
border: none;
border-radius: $radius;
background: $color-text;
color: $color-background;
font-size: 0.8rem;
line-height: 1.4;
text-align: left;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
--hint-arrow-top: 100%;
--hint-arrow-bottom: auto;
--hint-arrow-color-top: #{$color-text};
--hint-arrow-color-bottom: transparent;
&::after {
content: '';
position: absolute;
left: 50%;
top: var(--hint-arrow-top);
bottom: var(--hint-arrow-bottom);
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--hint-arrow-color-top);
border-bottom-color: var(--hint-arrow-color-bottom);
}
}
.cancelButton {
padding: $space-1 $space-2;
border: none;
+72 -3
View File
@@ -16,6 +16,16 @@ const helpMailto = (orderId: number, item: OrderItemStatusView) => {
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
}
const cancelBlockedReason = (item: OrderItemStatusView) => {
if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') {
return null
}
if (item.status === 'ready') {
return "This item is already done cooking, so it's too late to cancel. If something's wrong with it, use Get help below."
}
return "This item has already started cooking, so the kitchen can't stop or undo it. If something's wrong, use Get help below."
}
const BackArrowIcon = () => (
<svg
className={styles.backArrow}
@@ -32,9 +42,27 @@ const BackArrowIcon = () => (
</svg>
)
const InfoIcon = () => (
<svg
className={styles.infoIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="11" x2="12" y2="16" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
)
export type OrderStatusResult = {
id: number
status: OrderStatus
createdAt: Date
items: Array<OrderItemStatusView>
}
@@ -73,15 +101,34 @@ export const OrderStatusView = ({
<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>
{error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}>
{status.items.map((item) => (
{status.items.map((item) => {
const cancelHint = cancelBlockedReason(item)
return (
<li key={item.id} className={styles.item}>
<div className={styles.itemHeader}>
<span className={styles.itemName}>{item.menuItemName}</span>
<span className={styles.itemStatus}>{item.status}</span>
</div>
{item.options.length > 0 && (
<p className={styles.itemOptions}>
{item.options.map((option) => option.name).join(', ')}
</p>
)}
<ol className={styles.timeline}>
{item.steps.map((step) => (
<li
@@ -118,7 +165,6 @@ export const OrderStatusView = ({
Buy again
</button>
{item.status === 'ready' && (
<button
type="button"
@@ -135,6 +181,28 @@ export const OrderStatusView = ({
</button>
)}
<a href={helpMailto(status.id, item)}>Get help</a>
{cancelHint && (
<>
<button
type="button"
className={styles.infoButton}
popoverTarget={`cancel-hint-${item.id}`}
style={{ anchorName: `--cancel-hint-anchor-${item.id}` }}
>
<InfoIcon />
Why can't I cancel?
</button>
<p
id={`cancel-hint-${item.id}`}
popover="auto"
role="tooltip"
className={styles.cancelHint}
style={{ positionAnchor: `--cancel-hint-anchor-${item.id}` }}
>
{cancelHint}
</p>
</>
)}
{item.cancellable && (
<button
type="button"
@@ -153,7 +221,8 @@ export const OrderStatusView = ({
)}
</div>
</li>
))}
)
})}
</ul>
</section>
)
+1 -2
View File
@@ -1,6 +1,7 @@
import { Link } from '@tanstack/react-router'
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { formatPrice } from '#/lib/format.ts'
import styles from './OrdersListView.module.scss'
@@ -8,8 +9,6 @@ type OrdersListViewProps = {
orders: Array<CustomerOrderSummary>
}
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatItems = (items: CustomerOrderSummary['items']) =>
items
.map(({ menuItemName, quantity }) =>
+23
View File
@@ -65,6 +65,17 @@ export const menuItemSteps = pgTable('menu_item_steps', {
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),
})
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
export type OrderStatus = (typeof orderStatusValues)[number]
@@ -123,6 +134,18 @@ export const orderItemSteps = pgTable('order_item_steps', {
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 = [
'queued',
'in_progress',
+19
View File
@@ -100,6 +100,15 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
},
])
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
.insert(schema.menuItems)
.values({
@@ -127,6 +136,15 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
},
])
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()
return {
brand,
machineTags: {
@@ -137,6 +155,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
},
machines: { prep1, oven1, grill1, assembly1 },
menuItems: { pizza, burger },
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
}
}
+80 -14
View File
@@ -57,6 +57,11 @@ export type MenuItemWithRecipe = {
machineTag: string
estimatedDurationSeconds: number
}>
options: Array<{
id: number
name: string
priceDeltaCents: number
}>
}
export const getMenu = async (
@@ -68,6 +73,8 @@ export const getMenu = async (
.from(schema.menuItems)
.where(eq(schema.menuItems.brandId, brandId))
const menuItemIds = items.map((item) => item.id)
const steps = await db
.select({
menuItemId: schema.menuItemSteps.menuItemId,
@@ -81,14 +88,17 @@ export const getMenu = async (
schema.machineTags,
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
)
.where(
inArray(
schema.menuItemSteps.menuItemId,
items.map((item) => item.id),
),
)
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
.orderBy(asc(schema.menuItemSteps.sequence))
const options =
menuItemIds.length === 0
? []
: await db
.select()
.from(schema.menuItemOptions)
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
return items.map((item) => ({
id: item.id,
name: item.name,
@@ -102,6 +112,9 @@ export const getMenu = async (
machineTag,
estimatedDurationSeconds,
})),
options: options
.filter((option) => option.menuItemId === item.id)
.map(({ id, name, priceDeltaCents }) => ({ id, name, priceDeltaCents })),
}))
}
@@ -231,7 +244,7 @@ const initializeOrderItemRecipe = async (
}
export type PlaceOrderInput = {
items: Array<{ menuItemId: number }>
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
customer:
| { type: 'guest'; name: string; contact: string }
| { type: 'account'; customerId: string }
@@ -276,8 +289,39 @@ export const placeOrder = async (
}
}
const totalCents = input.items.reduce(
(sum, item) => sum + menuItemById.get(item.menuItemId)!.priceCents,
const allSelectedOptionIds = [
...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,
)
@@ -313,22 +357,31 @@ export const placeOrder = async (
const tagsToAssign = new Set<number>()
for (const item of input.items) {
const menuItem = menuItemById.get(item.menuItemId)!
for (const item of resolvedItems) {
const [orderItem] = await tx
.insert(schema.orderItems)
.values({
orderId: order.id,
menuItemId: menuItem.id,
priceCents: menuItem.priceCents,
menuItemId: item.menuItemId,
priceCents: item.priceCents,
status: 'paid',
})
.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(
tx,
orderItem.id,
menuItem.id,
item.menuItemId,
)
tagsToAssign.add(step.machineTagId)
}
@@ -592,6 +645,7 @@ export type OrderItemStatusView = {
status: 'done' | 'in_progress' | 'queued' | 'pending'
queuePosition: number | null
}>
options: Array<{ name: string; priceDeltaCents: number }>
etaSeconds: number | null
}
@@ -623,6 +677,14 @@ export const getOrderStatus = async (
).map((row) => [row.id, row]),
)
const optionRows =
orderItemIds.length === 0
? []
: await db
.select()
.from(schema.orderItemOptions)
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
const recipeRows =
orderItemIds.length === 0
? []
@@ -802,6 +864,9 @@ export const getOrderStatus = async (
item.status !== 'picked_up' &&
currentInstance?.status === 'queued',
steps,
options: optionRows
.filter((option) => option.orderItemId === item.id)
.map(({ name, priceDeltaCents }) => ({ name, priceDeltaCents })),
etaSeconds,
}
})
@@ -809,6 +874,7 @@ export const getOrderStatus = async (
return {
id: order.id,
status: order.status,
createdAt: order.createdAt,
items: itemViews,
}
}
+78 -23
View File
@@ -9,71 +9,126 @@ 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 = {
quantities: Record<number, number>
lines: Array<BasketLine>
itemCount: number
add: (menuItemId: number) => void
remove: (menuItemId: number) => void
add: (menuItemId: number, optionIds?: Array<number>) => void
remove: (menuItemId: number, optionIds?: Array<number>) => void
clear: () => void
}
const BasketContext = createContext<BasketContextValue | null>(null)
const readStoredQuantities = (): Record<number, number> => {
const readStoredLines = (): StoredLines => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<number, number>) : {}
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 [quantities, setQuantities] = useState<Record<number, number>>({})
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(() => {
setQuantities(readStoredQuantities())
setStoredLines(readStoredLines())
}, [])
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quantities))
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.
}
}, [quantities])
}, [storedLines])
const add = (menuItemId: number) =>
setQuantities((prev) => ({
const add = (menuItemId: number, optionIds: Array<number> = []) => {
const key = lineKey(menuItemId, optionIds)
setStoredLines((prev) => ({
...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
[key]: {
menuItemId,
optionIds,
quantity: (prev[key]?.quantity ?? 0) + 1,
},
}))
}
const remove = (menuItemId: number) =>
setQuantities((prev) => {
const current = prev[menuItemId] ?? 0
if (current <= 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[menuItemId]
delete next[key]
return next
}
return { ...prev, [menuItemId]: current - 1 }
return { ...prev, [key]: { ...current, quantity: current.quantity - 1 } }
})
}
const clear = () => setQuantities({})
const clear = () => setStoredLines({})
const lines = useMemo(
() => Object.values(storedLines).filter((line) => line !== undefined),
[storedLines],
)
const itemCount = useMemo(
() => Object.values(quantities).reduce((sum, quantity) => sum + quantity, 0),
[quantities],
() => lines.reduce((sum, line) => sum + line.quantity, 0),
[lines],
)
const value = useMemo(
() => ({ quantities, itemCount, add, remove, clear }),
[quantities, itemCount],
() => ({ lines, itemCount, add, remove, clear }),
[lines, itemCount],
)
return (
+30
View File
@@ -1,3 +1,33 @@
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)
+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.
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 = {
chargeId: string
amountCents: number
@@ -64,7 +69,7 @@ export const createStripePaymentProvider = (
charge: async (amountCents) => {
const intent = await stripe.paymentIntents.create({
amount: amountCents,
currency: 'usd',
currency,
confirm: true,
payment_method: 'pm_card_visa',
automatic_payment_methods: {
+21
View File
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as BasketRouteImport } from './routes/basket'
import { Route as LoginRouteImport } from './routes/login'
import { Route as SignupRouteImport } from './routes/signup'
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 ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -37,6 +38,11 @@ const SignupRoute = SignupRouteImport.update({
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
id: '/items/$menuItemId',
path: '/items/$menuItemId',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersIndexRoute = OrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
@@ -67,6 +74,7 @@ export interface FileRoutesByTo {
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
@@ -77,6 +85,7 @@ export interface FileRoutesById {
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
@@ -88,6 +97,7 @@ export interface FileRouteTypes {
| '/basket'
| '/login'
| '/signup'
| '/items/$menuItemId'
| '/orders/$orderId'
| '/orders/'
| '/api/auth/$'
@@ -97,6 +107,7 @@ export interface FileRouteTypes {
| '/basket'
| '/login'
| '/signup'
| '/items/$menuItemId'
| '/orders/$orderId'
| '/orders'
| '/api/auth/$'
@@ -106,6 +117,7 @@ export interface FileRouteTypes {
| '/basket'
| '/login'
| '/signup'
| '/items/$menuItemId'
| '/orders/$orderId'
| '/orders/'
| '/api/auth/$'
@@ -116,6 +128,7 @@ export interface RootRouteChildren {
BasketRoute: typeof BasketRoute
LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
OrdersIndexRoute: typeof OrdersIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
@@ -151,6 +164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport
}
'/items/$menuItemId': {
id: '/items/$menuItemId'
path: '/items/$menuItemId'
fullPath: '/items/$menuItemId'
preLoaderRoute: typeof ItemsMenuItemIdRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/': {
id: '/orders/'
path: '/orders'
@@ -180,6 +200,7 @@ const rootRouteChildren: RootRouteChildren = {
BasketRoute: BasketRoute,
LoginRoute: LoginRoute,
SignupRoute: SignupRoute,
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
OrdersOrderIdRoute: OrdersOrderIdRoute,
OrdersIndexRoute: OrdersIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
+62 -4
View File
@@ -10,11 +10,56 @@
margin: 0 0 $space-4;
}
.empty {
color: $color-text-muted;
.stack {
display: grid;
a {
color: $color-primary;
> * {
grid-area: 1 / 1;
min-width: 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;
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 {
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;
}
}
@@ -48,6 +93,14 @@
font-weight: 600;
}
.lineOptions {
list-style: disc;
margin: 0;
padding-left: 1.1rem;
color: $color-text-muted;
font-size: 0.8rem;
}
.linePrice {
color: $color-text-muted;
font-size: 0.9rem;
@@ -68,6 +121,11 @@
cursor: pointer;
align-self: center;
justify-self: center;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
}
.count {
+91 -22
View File
@@ -1,77 +1,144 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { BasketIcon } from '#/components/basket/BasketLink.tsx'
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import { useBasket } from '#/lib/basket.tsx'
import { formatPrice } from '#/lib/format.ts'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
import styles from './basket.module.scss'
const optionNames = (item: MenuItemWithRecipe, optionIds: Array<number>) =>
optionIds
.map((id) => item.options.find((option) => option.id === id)?.name)
.filter((name): name is string => Boolean(name))
const BasketPage = () => {
const { menu } = Route.useLoaderData()
const { quantities, add, remove, clear } = useBasket()
const { lines, add, remove, clear } = useBasket()
const navigate = useNavigate()
const reduceMotion = useReducedMotion()
const lines = menu
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
.filter(({ quantity }) => quantity > 0)
const resolvedLines = lines
.map((line) => ({
line,
item: menu.find((item) => item.id === line.menuItemId),
}))
.filter(
(resolved): resolved is { line: (typeof lines)[number]; item: MenuItemWithRecipe } =>
resolved.item !== undefined,
)
.map(({ line, item }) => {
const optionsTotalCents = line.optionIds.reduce((sum, optionId) => {
const option = item.options.find((o) => o.id === optionId)
return sum + (option?.priceDeltaCents ?? 0)
}, 0)
const unitPriceCents = item.priceCents + optionsTotalCents
return { line, item, unitPriceCents }
})
const totalCents = lines.reduce(
(sum, { item, quantity }) => sum + item.priceCents * quantity,
const totalCents = resolvedLines.reduce(
(sum, { line, unitPriceCents }) => sum + unitPriceCents * line.quantity,
0,
)
const items = lines.flatMap(({ item, quantity }) =>
Array.from({ length: quantity }, () => ({ menuItemId: item.id })),
const items = resolvedLines.flatMap(({ line }) =>
Array.from({ length: line.quantity }, () => ({
menuItemId: line.menuItemId,
selectedOptionIds: line.optionIds,
})),
)
const isEmpty = resolvedLines.length === 0
const transition = { duration: reduceMotion ? 0 : 0.2, ease: 'easeOut' as const }
return (
<main className={styles.page}>
<h1 className={styles.heading}>Your basket</h1>
{lines.length === 0 ? (
<p className={styles.empty}>
Your basket is empty — <Link to="/">browse the menu</Link> to add
something.
<div className={styles.stack}>
<AnimatePresence mode="wait">
{isEmpty ? (
<motion.div
key="empty"
className={styles.empty}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
>
<BasketIcon />
<p className={styles.emptyTitle}>Your basket is empty</p>
<p className={styles.emptyText}>
Add something tasty from the menu to get started.
</p>
<Link to="/" className={styles.emptyCta}>
Browse the menu
</Link>
</motion.div>
) : (
<>
<motion.div
key="items"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
>
<ul className={styles.lines}>
{lines.map(({ item, quantity }) => (
<li key={item.id} className={styles.line}>
{resolvedLines.map(({ line, item, unitPriceCents }) => {
const names = optionNames(item, line.optionIds)
return (
<li
key={`${line.menuItemId}:${line.optionIds.join(',')}`}
className={styles.line}
>
<div className={styles.lineInfo}>
<span className={styles.lineName}>{item.name}</span>
{names.length > 0 && (
<ul className={styles.lineOptions}>
{names.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
)}
<span className={styles.linePrice}>
${((item.priceCents * quantity) / 100).toFixed(2)}
{formatPrice(unitPriceCents * line.quantity)}
</span>
</div>
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => remove(item.id)}
onClick={() => remove(line.menuItemId, line.optionIds)}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<AnimatedNumber value={quantity} className={styles.count} />
<AnimatedNumber
value={line.quantity}
className={styles.count}
/>
<button
type="button"
className={styles.stepperButton}
onClick={() => add(item.id)}
onClick={() => add(line.menuItemId, line.optionIds)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</li>
))}
)
})}
</ul>
<div className={styles.total}>
<span>Total</span>
<span>${(totalCents / 100).toFixed(2)}</span>
<span>{formatPrice(totalCents)}</span>
</div>
<CheckoutSection
@@ -88,8 +155,10 @@ const BasketPage = () => {
clear()
}}
/>
</>
</motion.div>
)}
</AnimatePresence>
</div>
</main>
)
}
+5
View File
@@ -5,3 +5,8 @@
margin: 0 auto;
padding: $space-5 $space-3;
}
.intro {
margin: 0 0 $space-4;
color: $color-text-muted;
}
+7 -7
View File
@@ -9,16 +9,16 @@ import styles from './index.module.scss'
const OrderingPage = () => {
const { menu } = Route.useLoaderData()
const { quantities, add, remove } = useBasket()
const { lines, add, remove } = useBasket()
return (
<main className={styles.page}>
<MenuList
menu={menu}
quantities={quantities}
onAdd={add}
onRemove={remove}
/>
<p className={styles.intro}>
Zero cooks, all machine. A robotic kitchen grills, assembles, and
plates your order in real time — watch it happen live, from click to
pickup.
</p>
<MenuList menu={menu} lines={lines} onAdd={add} onRemove={remove} />
</main>
)
}
+32
View File
@@ -0,0 +1,32 @@
@use '../../styles/variables' as *;
.page {
max-width: 640px;
margin: 0 auto;
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;
}
+55
View File
@@ -0,0 +1,55 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { MenuItemDetail } from '#/components/ordering/MenuItemDetail.tsx'
import { useBasket } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
import styles from './$menuItemId.module.scss'
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 MenuItemRoute = () => {
const { item } = Route.useLoaderData()
const { lines, add, remove } = useBasket()
return (
<section className={styles.page}>
<Link to="/" className={styles.backLink}>
<BackArrowIcon />
Back to menu
</Link>
<MenuItemDetail
item={item}
lines={lines.filter((line) => line.menuItemId === item.id)}
onAdd={add}
onRemove={remove}
/>
</section>
)
}
export const Route = createFileRoute('/items/$menuItemId')({
loader: async ({ params }) => {
const brand = await getBrand()
const menu = await getMenu({ data: { brandId: brand.id } })
const item = menu.find((menuItem) => menuItem.id === Number(params.menuItemId))
if (!item) throw new Error('Menu item not found')
return { item }
},
component: MenuItemRoute,
})
+5
View File
@@ -30,6 +30,11 @@
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
+5
View File
@@ -30,6 +30,11 @@
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
+8 -1
View File
@@ -26,7 +26,14 @@ const customerSchema = z.discriminatedUnion('type', [
export const placeOrder = createServerFn({ method: 'POST' })
.validator(
z.object({
items: z.array(z.object({ menuItemId: z.number() })).min(1),
items: z
.array(
z.object({
menuItemId: z.number(),
selectedOptionIds: z.array(z.number()).optional(),
}),
)
.min(1),
customer: customerSchema,
}),
)
+111 -2
View File
@@ -41,18 +41,19 @@ beforeEach(() => {
afterEach(async () => {
await testDb.db.delete(schema.preparationStepInstances)
await testDb.db.delete(schema.orderItemSteps)
await testDb.db.delete(schema.orderItemOptions)
await testDb.db.delete(schema.orderItems)
await testDb.db.delete(schema.orders)
})
const placeGuestOrder = (items: Array<{ menuItemId: number }>) =>
const placeGuestOrder = (items: ordering.PlaceOrderInput['items']) =>
ordering.placeOrder(testDb.db, payment, {
items,
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
})
const placeAccountOrder = (
items: Array<{ menuItemId: number }>,
items: ordering.PlaceOrderInput['items'],
customerId: string,
) =>
ordering.placeOrder(testDb.db, payment, {
@@ -130,6 +131,28 @@ describe('menu', () => {
},
])
})
it('lists each menu item\'s customization Options', async () => {
const menu = await ordering.getMenu(testDb.db, seed.brand.id)
const pizza = menu.find((item) => item.name === 'Pizza')
expect(pizza?.options).toEqual(
seed.menuItemOptions.pizza.map((option) => ({
id: option.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
const burger = menu.find((item) => item.name === 'Burger')
expect(burger?.options).toEqual(
seed.menuItemOptions.burger.map((option) => ({
id: option.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
})
})
describe('placing an order', () => {
@@ -244,6 +267,92 @@ describe('placing an order', () => {
})
})
describe('customizing an item', () => {
it("charges the adjusted price for selected Options and snapshots them on the Order Item", async () => {
const extraCheese = seed.menuItemOptions.pizza.find(
(option) => option.name === 'Extra cheese',
)!
const noOnions = seed.menuItemOptions.pizza.find(
(option) => option.name === 'No onions',
)!
let chargedAmountCents: number | undefined
const spyingPayment: typeof payment = {
...payment,
charge: async (amountCents) => {
chargedAmountCents = amountCents
return payment.charge(amountCents)
},
}
const order = await ordering.placeOrder(testDb.db, spyingPayment, {
items: [
{
menuItemId: seed.menuItems.pizza.id,
selectedOptionIds: [extraCheese.id, noOnions.id],
},
],
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
})
expect(chargedAmountCents).toBe(
seed.menuItems.pizza.priceCents + extraCheese.priceDeltaCents,
)
const status = await getStatus(order)
expect(status.items[0].options).toEqual(
expect.arrayContaining([
{ name: 'Extra cheese', priceDeltaCents: 150 },
{ name: 'No onions', priceDeltaCents: 0 },
]),
)
expect(status.items[0].options).toHaveLength(2)
})
it('places an unmodified Order Item with no Options selected', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const status = await getStatus(order)
expect(status.items[0].options).toEqual([])
})
it("rejects an Option that doesn't belong to the selected menu item", async () => {
const burgerOption = seed.menuItemOptions.burger[0]
await expect(
placeGuestOrder([
{
menuItemId: seed.menuItems.pizza.id,
selectedOptionIds: [burgerOption.id],
},
]),
).rejects.toThrow(/not valid for menu item/)
})
it('refunds the full customized price on cancellation', async () => {
const extraPatty = seed.menuItemOptions.burger.find(
(option) => option.name === 'Extra patty',
)!
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
const order = await placeGuestOrder([
{
menuItemId: seed.menuItems.burger.id,
selectedOptionIds: [extraPatty.id],
},
])
const itemId = (await getStatus(order)).items[0].id
await ordering.cancelOrderItem(testDb.db, payment, itemId, accessFor(order))
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
seed.menuItems.burger.priceCents + extraPatty.priceDeltaCents,
)
})
})
describe('cancelling an Order Item', () => {
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station