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_URL=http://localhost:3000
BETTER_AUTH_SECRET= 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 # 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. # sign-in and sign-up pages. Leave unset to fall back to email/password only.
GOOGLE_CLIENT_ID= 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, "when": 1789028563859,
"tag": "0003_soft_psynapse", "tag": "0003_soft_psynapse",
"breakpoints": true "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; border: 1px solid $color-border;
&:hover { &: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 MotionLink = motion.create(Link)
const BasketIcon = () => ( export const BasketIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
@@ -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;
+1 -1
View File
@@ -19,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
} }
@@ -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 { .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 {
@@ -56,6 +83,11 @@
cursor: pointer; cursor: pointer;
align-self: center; align-self: center;
justify-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;
+61 -35
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 { 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 && (
@@ -42,25 +47,46 @@ export const MenuList = ({
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)} {formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p> </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) }}
<AnimatedNumber value={quantity} className={styles.count} /> 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}`}
>
−
</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}`}
>
+
</button>
</div>
</div> </div>
</li> </li>
) )
@@ -1,5 +1,13 @@
@use '../../styles/variables' as *; @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 { .page {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
@@ -36,11 +44,17 @@
} }
.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;
}
.error { .error {
color: $color-danger; color: $color-danger;
} }
@@ -73,6 +87,12 @@
color: $color-text-muted; color: $color-text-muted;
} }
.itemOptions {
margin: 0 0 $space-2;
font-size: 0.85rem;
color: $color-text-muted;
}
.timeline { .timeline {
margin: 0 0 $space-2; margin: 0 0 $space-2;
padding: 0; padding: 0;
@@ -194,6 +214,7 @@
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: $space-2; gap: $space-2;
align-items: center; align-items: center;
@@ -206,6 +227,11 @@
color: $color-text; color: $color-text;
text-decoration: none; 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;
@@ -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 { .cancelButton {
padding: $space-1 $space-2; padding: $space-1 $space-2;
border: none; border: none;
+135 -66
View File
@@ -16,6 +16,16 @@ const helpMailto = (orderId: number, item: OrderItemStatusView) => {
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}` 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 = () => ( const BackArrowIcon = () => (
<svg <svg
className={styles.backArrow} className={styles.backArrow}
@@ -32,9 +42,27 @@ const BackArrowIcon = () => (
</svg> </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 = { export type OrderStatusResult = {
id: number id: number
status: OrderStatus status: OrderStatus
createdAt: Date
items: Array<OrderItemStatusView> items: Array<OrderItemStatusView>
} }
@@ -73,87 +101,128 @@ export const OrderStatusView = ({
<div style={{ viewTransitionName: `order-card-${status.id}` }}> <div style={{ viewTransitionName: `order-card-${status.id}` }}>
<h1 className={styles.heading}>Order #{status.id}</h1> <h1 className={styles.heading}>Order #{status.id}</h1>
<p className={styles.orderStatus}>Status: {status.status}</p> <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> </div>
{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 cancelHint = cancelBlockedReason(item)
<div className={styles.itemHeader}> return (
<span className={styles.itemName}>{item.menuItemName}</span> <li key={item.id} className={styles.item}>
<span className={styles.itemStatus}>{item.status}</span> <div className={styles.itemHeader}>
</div> <span className={styles.itemName}>{item.menuItemName}</span>
<ol className={styles.timeline}> <span className={styles.itemStatus}>{item.status}</span>
{item.steps.map((step) => ( </div>
<li {item.options.length > 0 && (
key={step.sequence} <p className={styles.itemOptions}>
className={styles.step} {item.options.map((option) => option.name).join(', ')}
data-status={step.status} </p>
aria-current={step.status === 'in_progress' ? 'step' : undefined} )}
> <ol className={styles.timeline}>
<span className={styles.stepMarker} aria-hidden="true"> {item.steps.map((step) => (
<span className={styles.stepDot} /> <li
</span> key={step.sequence}
<span className={styles.stepContent}> className={styles.step}
<span className={styles.stepLabel}>{step.name}</span> data-status={step.status}
{step.status === 'queued' && step.queuePosition !== null && ( aria-current={step.status === 'in_progress' ? 'step' : undefined}
<span className={styles.stepMeta}> >
#{step.queuePosition} in line <span className={styles.stepMarker} aria-hidden="true">
</span> <span className={styles.stepDot} />
)} </span>
</span> <span className={styles.stepContent}>
</li> <span className={styles.stepLabel}>{step.name}</span>
))} {step.status === 'queued' && step.queuePosition !== null && (
</ol> <span className={styles.stepMeta}>
{item.etaSeconds !== null && ( #{step.queuePosition} in line
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p> </span>
)} )}
<div className={styles.actions}> </span>
<button </li>
))}
</ol>
{item.etaSeconds !== null && (
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
)}
<div className={styles.actions}>
<button
type="button" type="button"
disabled={pendingItemId === item.id} disabled={pendingItemId === item.id}
onClick={() => { onClick={() => {
// //
}} }}
>
Buy again
</button>
{item.status === 'ready' && (
<button
type="button"
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
markOrderItemPickedUp({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
> >
I picked this up Buy again
</button> </button>
)}
<a href={helpMailto(status.id, item)}>Get help</a> {item.status === 'ready' && (
{item.cancellable && (
<button <button
type="button"
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
markOrderItemPickedUp({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
I picked this up
</button>
)}
<a href={helpMailto(status.id, item)}>Get help</a>
{cancelHint && (
<>
<button
type="button" type="button"
className={styles.cancelButton} className={styles.infoButton}
disabled={pendingItemId === item.id} popoverTarget={`cancel-hint-${item.id}`}
onClick={() => style={{ anchorName: `--cancel-hint-anchor-${item.id}` }}
void runAction(item.id, () => >
cancelOrderItem({ <InfoIcon />
data: { orderItemId: item.id, guestAccessToken }, 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"
className={styles.cancelButton}
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
cancelOrderItem({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
> >
Cancel Cancel
</button> </button>
)} )}
</div> </div>
</li> </li>
))} )
})}
</ul> </ul>
</section> </section>
) )
+1 -2
View File
@@ -1,6 +1,7 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { CustomerOrderSummary } from '#/domain/ordering.ts' import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { formatPrice } from '#/lib/format.ts'
import styles from './OrdersListView.module.scss' import styles from './OrdersListView.module.scss'
@@ -8,8 +9,6 @@ type OrdersListViewProps = {
orders: Array<CustomerOrderSummary> orders: Array<CustomerOrderSummary>
} }
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatItems = (items: CustomerOrderSummary['items']) => const formatItems = (items: CustomerOrderSummary['items']) =>
items items
.map(({ menuItemName, quantity }) => .map(({ menuItemName, quantity }) =>
+23
View File
@@ -65,6 +65,17 @@ export const menuItemSteps = pgTable('menu_item_steps', {
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),
})
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]
@@ -123,6 +134,18 @@ export const orderItemSteps = pgTable('order_item_steps', {
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',
+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 const [burger] = await db
.insert(schema.menuItems) .insert(schema.menuItems)
.values({ .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 { return {
brand, brand,
machineTags: { machineTags: {
@@ -137,6 +155,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
}, },
machines: { prep1, oven1, grill1, assembly1 }, machines: { prep1, oven1, grill1, assembly1 },
menuItems: { pizza, burger }, menuItems: { pizza, burger },
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
} }
} }
+80 -14
View File
@@ -57,6 +57,11 @@ export type MenuItemWithRecipe = {
machineTag: string machineTag: string
estimatedDurationSeconds: number estimatedDurationSeconds: number
}> }>
options: Array<{
id: number
name: string
priceDeltaCents: number
}>
} }
export const getMenu = async ( export const getMenu = async (
@@ -68,6 +73,8 @@ 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,
@@ -81,14 +88,17 @@ 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))
return items.map((item) => ({ return items.map((item) => ({
id: item.id, id: item.id,
name: item.name, name: item.name,
@@ -102,6 +112,9 @@ export const getMenu = async (
machineTag, machineTag,
estimatedDurationSeconds, 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 = { 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 }
@@ -276,8 +289,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,
) )
@@ -313,22 +357,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)
} }
@@ -592,6 +645,7 @@ export type OrderItemStatusView = {
status: 'done' | 'in_progress' | 'queued' | 'pending' status: 'done' | 'in_progress' | 'queued' | 'pending'
queuePosition: number | null queuePosition: number | null
}> }>
options: Array<{ name: string; priceDeltaCents: number }>
etaSeconds: number | null etaSeconds: number | null
} }
@@ -623,6 +677,14 @@ 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 recipeRows = const recipeRows =
orderItemIds.length === 0 orderItemIds.length === 0
? [] ? []
@@ -802,6 +864,9 @@ export const getOrderStatus = async (
item.status !== 'picked_up' && item.status !== 'picked_up' &&
currentInstance?.status === 'queued', currentInstance?.status === 'queued',
steps, steps,
options: optionRows
.filter((option) => option.orderItemId === item.id)
.map(({ name, priceDeltaCents }) => ({ name, priceDeltaCents })),
etaSeconds, etaSeconds,
} }
}) })
@@ -809,6 +874,7 @@ 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,
} }
} }
+78 -23
View File
@@ -9,71 +9,126 @@ import type { ReactNode } from 'react'
const STORAGE_KEY = 'ghost-kitchen-basket' 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 = { type BasketContextValue = {
quantities: Record<number, number> lines: Array<BasketLine>
itemCount: number itemCount: number
add: (menuItemId: number) => void add: (menuItemId: number, optionIds?: Array<number>) => void
remove: (menuItemId: number) => void remove: (menuItemId: number, optionIds?: Array<number>) => void
clear: () => void clear: () => void
} }
const BasketContext = createContext<BasketContextValue | null>(null) const BasketContext = createContext<BasketContextValue | null>(null)
const readStoredQuantities = (): Record<number, number> => { const readStoredLines = (): StoredLines => {
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY) 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 { } catch {
return {} return {}
} }
} }
export const BasketProvider = ({ children }: { children: ReactNode }) => { 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 // Read from localStorage after mount, not during initial render, so the
// server-rendered and first client-rendered HTML match (no localStorage on // server-rendered and first client-rendered HTML match (no localStorage on
// the server) and React doesn't flag a hydration mismatch. // the server) and React doesn't flag a hydration mismatch.
useEffect(() => { useEffect(() => {
setQuantities(readStoredQuantities()) setStoredLines(readStoredLines())
}, []) }, [])
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quantities)) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(storedLines))
} catch { } catch {
// Unavailable (private mode, quota) — basket still works for this // Unavailable (private mode, quota) — basket still works for this
// page load, it just won't persist across a reload. // page load, it just won't persist across a reload.
} }
}, [quantities]) }, [storedLines])
const add = (menuItemId: number) => const add = (menuItemId: number, optionIds: Array<number> = []) => {
setQuantities((prev) => ({ const key = lineKey(menuItemId, optionIds)
setStoredLines((prev) => ({
...prev, ...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1, [key]: {
menuItemId,
optionIds,
quantity: (prev[key]?.quantity ?? 0) + 1,
},
})) }))
}
const remove = (menuItemId: number) => const remove = (menuItemId: number, optionIds: Array<number> = []) => {
setQuantities((prev) => { const key = lineKey(menuItemId, optionIds)
const current = prev[menuItemId] ?? 0 setStoredLines((prev) => {
if (current <= 1) { const current = prev[key]
if (!current) return prev
if (current.quantity <= 1) {
const next = { ...prev } const next = { ...prev }
delete next[menuItemId] delete next[key]
return next 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( const itemCount = useMemo(
() => Object.values(quantities).reduce((sum, quantity) => sum + quantity, 0), () => lines.reduce((sum, line) => sum + line.quantity, 0),
[quantities], [lines],
) )
const value = useMemo( const value = useMemo(
() => ({ quantities, itemCount, add, remove, clear }), () => ({ lines, itemCount, add, remove, clear }),
[quantities, itemCount], [lines, itemCount],
) )
return ( 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) => { export const formatDuration = (totalSeconds: number) => {
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60)) const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60))
const hours = Math.floor(totalMinutes / 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. // 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: {
+21
View File
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as BasketRouteImport } from './routes/basket' import { Route as BasketRouteImport } from './routes/basket'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as SignupRouteImport } from './routes/signup' 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 OrdersIndexRouteImport } from './routes/orders/index'
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId' import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -37,6 +38,11 @@ const SignupRoute = SignupRouteImport.update({
path: '/signup', path: '/signup',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
id: '/items/$menuItemId',
path: '/items/$menuItemId',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersIndexRoute = OrdersIndexRouteImport.update({ const OrdersIndexRoute = OrdersIndexRouteImport.update({
id: '/orders/', id: '/orders/',
path: '/orders/', path: '/orders/',
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
'/basket': typeof BasketRoute '/basket': typeof BasketRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute '/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
@@ -67,6 +74,7 @@ export interface FileRoutesByTo {
'/basket': typeof BasketRoute '/basket': typeof BasketRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/orders': typeof OrdersIndexRoute '/orders': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
@@ -77,6 +85,7 @@ export interface FileRoutesById {
'/basket': typeof BasketRoute '/basket': typeof BasketRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute '/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
@@ -88,6 +97,7 @@ export interface FileRouteTypes {
| '/basket' | '/basket'
| '/login' | '/login'
| '/signup' | '/signup'
| '/items/$menuItemId'
| '/orders/$orderId' | '/orders/$orderId'
| '/orders/' | '/orders/'
| '/api/auth/$' | '/api/auth/$'
@@ -97,6 +107,7 @@ export interface FileRouteTypes {
| '/basket' | '/basket'
| '/login' | '/login'
| '/signup' | '/signup'
| '/items/$menuItemId'
| '/orders/$orderId' | '/orders/$orderId'
| '/orders' | '/orders'
| '/api/auth/$' | '/api/auth/$'
@@ -106,6 +117,7 @@ export interface FileRouteTypes {
| '/basket' | '/basket'
| '/login' | '/login'
| '/signup' | '/signup'
| '/items/$menuItemId'
| '/orders/$orderId' | '/orders/$orderId'
| '/orders/' | '/orders/'
| '/api/auth/$' | '/api/auth/$'
@@ -116,6 +128,7 @@ export interface RootRouteChildren {
BasketRoute: typeof BasketRoute BasketRoute: typeof BasketRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute SignupRoute: typeof SignupRoute
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
OrdersOrderIdRoute: typeof OrdersOrderIdRoute OrdersOrderIdRoute: typeof OrdersOrderIdRoute
OrdersIndexRoute: typeof OrdersIndexRoute OrdersIndexRoute: typeof OrdersIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
@@ -151,6 +164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SignupRouteImport preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/items/$menuItemId': {
id: '/items/$menuItemId'
path: '/items/$menuItemId'
fullPath: '/items/$menuItemId'
preLoaderRoute: typeof ItemsMenuItemIdRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/': { '/orders/': {
id: '/orders/' id: '/orders/'
path: '/orders' path: '/orders'
@@ -180,6 +200,7 @@ const rootRouteChildren: RootRouteChildren = {
BasketRoute: BasketRoute, BasketRoute: BasketRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
SignupRoute: SignupRoute, SignupRoute: SignupRoute,
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
OrdersOrderIdRoute: OrdersOrderIdRoute, OrdersOrderIdRoute: OrdersOrderIdRoute,
OrdersIndexRoute: OrdersIndexRoute, OrdersIndexRoute: OrdersIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
+62 -4
View File
@@ -10,11 +10,56 @@
margin: 0 0 $space-4; margin: 0 0 $space-4;
} }
.empty { .stack {
color: $color-text-muted; 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; font-weight: 600;
} }
.lineOptions {
list-style: disc;
margin: 0;
padding-left: 1.1rem;
color: $color-text-muted;
font-size: 0.8rem;
}
.linePrice { .linePrice {
color: $color-text-muted; color: $color-text-muted;
font-size: 0.9rem; font-size: 0.9rem;
@@ -68,6 +121,11 @@
cursor: pointer; cursor: pointer;
align-self: center; align-self: center;
justify-self: center; justify-self: center;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
} }
.count { .count {
+135 -66
View File
@@ -1,95 +1,164 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' 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 { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx' import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import { useBasket } from '#/lib/basket.tsx' import { useBasket } from '#/lib/basket.tsx'
import { formatPrice } from '#/lib/format.ts'
import { getBrand } from '#/server/brand.ts' import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts' import { getMenu } from '#/server/menu.ts'
import styles from './basket.module.scss' 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 BasketPage = () => {
const { menu } = Route.useLoaderData() const { menu } = Route.useLoaderData()
const { quantities, add, remove, clear } = useBasket() const { lines, add, remove, clear } = useBasket()
const navigate = useNavigate() const navigate = useNavigate()
const reduceMotion = useReducedMotion()
const lines = menu const resolvedLines = lines
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 })) .map((line) => ({
.filter(({ quantity }) => quantity > 0) 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( const totalCents = resolvedLines.reduce(
(sum, { item, quantity }) => sum + item.priceCents * quantity, (sum, { line, unitPriceCents }) => sum + unitPriceCents * line.quantity,
0, 0,
) )
const items = lines.flatMap(({ item, quantity }) => const items = resolvedLines.flatMap(({ line }) =>
Array.from({ length: quantity }, () => ({ menuItemId: item.id })), 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 ( return (
<main className={styles.page}> <main className={styles.page}>
<h1 className={styles.heading}>Your basket</h1> <h1 className={styles.heading}>Your basket</h1>
{lines.length === 0 ? ( <div className={styles.stack}>
<p className={styles.empty}> <AnimatePresence mode="wait">
Your basket is empty — <Link to="/">browse the menu</Link> to add {isEmpty ? (
something. <motion.div
</p> key="empty"
) : ( className={styles.empty}
<> initial={{ opacity: 0 }}
<ul className={styles.lines}> animate={{ opacity: 1 }}
{lines.map(({ item, quantity }) => ( exit={{ opacity: 0 }}
<li key={item.id} className={styles.line}> transition={transition}
<div className={styles.lineInfo}> >
<span className={styles.lineName}>{item.name}</span> <BasketIcon />
<span className={styles.linePrice}> <p className={styles.emptyTitle}>Your basket is empty</p>
${((item.priceCents * quantity) / 100).toFixed(2)} <p className={styles.emptyText}>
</span> Add something tasty from the menu to get started.
</div> </p>
<div className={styles.quantity}> <Link to="/" className={styles.emptyCta}>
<button Browse the menu
type="button" </Link>
className={styles.stepperButton} </motion.div>
onClick={() => remove(item.id)} ) : (
aria-label={`Remove one ${item.name}`} <motion.div
> key="items"
− initial={{ opacity: 0 }}
</button> animate={{ opacity: 1 }}
<AnimatedNumber value={quantity} className={styles.count} /> exit={{ opacity: 0 }}
<button transition={transition}
type="button" >
className={styles.stepperButton} <ul className={styles.lines}>
onClick={() => add(item.id)} {resolvedLines.map(({ line, item, unitPriceCents }) => {
aria-label={`Add one ${item.name}`} const names = optionNames(item, line.optionIds)
> return (
+ <li
</button> key={`${line.menuItemId}:${line.optionIds.join(',')}`}
</div> className={styles.line}
</li> >
))} <div className={styles.lineInfo}>
</ul> <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}>
{formatPrice(unitPriceCents * line.quantity)}
</span>
</div>
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => remove(line.menuItemId, line.optionIds)}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<AnimatedNumber
value={line.quantity}
className={styles.count}
/>
<button
type="button"
className={styles.stepperButton}
onClick={() => add(line.menuItemId, line.optionIds)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</li>
)
})}
</ul>
<div className={styles.total}> <div className={styles.total}>
<span>Total</span> <span>Total</span>
<span>${(totalCents / 100).toFixed(2)}</span> <span>{formatPrice(totalCents)}</span>
</div> </div>
<CheckoutSection <CheckoutSection
items={items} items={items}
disabled={items.length === 0} disabled={items.length === 0}
onPlaced={async (order) => { onPlaced={async (order) => {
await navigate({ await navigate({
to: '/orders/$orderId', to: '/orders/$orderId',
params: { orderId: String(order.id) }, params: { orderId: String(order.id) },
search: order.guestAccessToken search: order.guestAccessToken
? { token: order.guestAccessToken } ? { token: order.guestAccessToken }
: {}, : {},
}) })
clear() clear()
}} }}
/> />
</> </motion.div>
)} )}
</AnimatePresence>
</div>
</main> </main>
) )
} }
+5
View File
@@ -5,3 +5,8 @@
margin: 0 auto; margin: 0 auto;
padding: $space-5 $space-3; 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 OrderingPage = () => {
const { menu } = Route.useLoaderData() const { menu } = Route.useLoaderData()
const { quantities, add, remove } = useBasket() const { lines, add, remove } = useBasket()
return ( return (
<main className={styles.page}> <main className={styles.page}>
<MenuList <p className={styles.intro}>
menu={menu} Zero cooks, all machine. A robotic kitchen grills, assembles, and
quantities={quantities} plates your order in real time — watch it happen live, from click to
onAdd={add} pickup.
onRemove={remove} </p>
/> <MenuList menu={menu} lines={lines} onAdd={add} onRemove={remove} />
</main> </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; 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;
+5
View File
@@ -30,6 +30,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;
+8 -1
View File
@@ -26,7 +26,14 @@ const customerSchema = z.discriminatedUnion('type', [
export const placeOrder = createServerFn({ method: 'POST' }) export const placeOrder = createServerFn({ method: 'POST' })
.validator( .validator(
z.object({ 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, customer: customerSchema,
}), }),
) )
+111 -2
View File
@@ -41,18 +41,19 @@ beforeEach(() => {
afterEach(async () => { afterEach(async () => {
await testDb.db.delete(schema.preparationStepInstances) await testDb.db.delete(schema.preparationStepInstances)
await testDb.db.delete(schema.orderItemSteps) await testDb.db.delete(schema.orderItemSteps)
await testDb.db.delete(schema.orderItemOptions)
await testDb.db.delete(schema.orderItems) await testDb.db.delete(schema.orderItems)
await testDb.db.delete(schema.orders) await testDb.db.delete(schema.orders)
}) })
const placeGuestOrder = (items: Array<{ menuItemId: number }>) => const placeGuestOrder = (items: ordering.PlaceOrderInput['items']) =>
ordering.placeOrder(testDb.db, payment, { ordering.placeOrder(testDb.db, payment, {
items, items,
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' }, customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
}) })
const placeAccountOrder = ( const placeAccountOrder = (
items: Array<{ menuItemId: number }>, items: ordering.PlaceOrderInput['items'],
customerId: string, customerId: string,
) => ) =>
ordering.placeOrder(testDb.db, payment, { 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', () => { 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', () => { describe('cancelling an Order Item', () => {
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => { 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 await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station