Add menu item allergens, drop nav Help link, animate customizer price

- Menu items can now list allergens (shared catalog + join table),
  shown on the menu grid and item detail pages when applicable, and
  editable from the admin menu item form.
- Removed the top-nav "Help" link now that the footer links to it.
- The item customization page's price now uses the sliding
  AnimatedNumber, matching the basket line price.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:50:17 +02:00
co-authored by Claude Sonnet 5
parent 917fc275f9
commit 8481032e30
20 changed files with 1141 additions and 27 deletions
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "allergens" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "menu_item_allergens" (
"id" serial PRIMARY KEY NOT NULL,
"menu_item_id" integer NOT NULL,
"allergen_id" integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_allergen_id_allergens_id_fk" FOREIGN KEY ("allergen_id") REFERENCES "public"."allergens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "allergens_name_idx" ON "allergens" USING btree ("name");--> statement-breakpoint
CREATE UNIQUE INDEX "menu_item_allergens_item_allergen_idx" ON "menu_item_allergens" USING btree ("menu_item_id","allergen_id");
+870
View File
@@ -0,0 +1,870 @@
{
"id": "4086d6cd-5c93-4617-a15b-a90331b459a1",
"prevId": "83dc14cf-82fb-43fc-8b7d-fd377bf48689",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allergens": {
"name": "allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"allergens_name_idx": {
"name": "allergens_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_allergens": {
"name": "menu_item_allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"allergen_id": {
"name": "allergen_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"menu_item_allergens_item_allergen_idx": {
"name": "menu_item_allergens_item_allergen_idx",
"columns": [
{
"expression": "menu_item_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "allergen_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_allergens_allergen_id_allergens_id_fk": {
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "allergens",
"columnsFrom": [
"allergen_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -43,6 +43,13 @@
"when": 1789636006749,
"tag": "0005_tan_power_man",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1789637974330,
"tag": "0006_worried_shooting_star",
"breakpoints": true
}
]
}
@@ -67,6 +67,9 @@ export const AdminMenuListView = ({
{item.steps.length === 1 ? '' : 's'} ·{' '}
{item.options.length} option
{item.options.length === 1 ? '' : 's'}
{item.allergens.length > 0
? ` · Contains: ${item.allergens.map((allergen) => allergen.name).join(', ')}`
: ''}
{item.description ? ` · ${item.description}` : ''}
</p>
</div>
@@ -117,6 +117,21 @@
}
}
.allergenList {
display: flex;
flex-wrap: wrap;
gap: $space-3;
}
.allergenOption {
display: flex;
align-items: center;
gap: $space-1;
font-size: 0.9rem;
color: $color-text;
cursor: pointer;
}
.actions {
display: flex;
align-items: center;
+35 -1
View File
@@ -2,7 +2,11 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import type { AdminMenuItem, MenuItemInput } from '#/domain/menu.ts'
import type {
AdminMenuItem,
AllergenOption,
MenuItemInput,
} from '#/domain/menu.ts'
import type { MachineTagOption } from '#/domain/kitchen.ts'
import styles from './MenuItemForm.module.scss'
@@ -25,6 +29,7 @@ const toStepRows = (item?: AdminMenuItem): Array<StepRow> =>
type MenuItemFormProps = {
machineTags: Array<MachineTagOption>
allergenOptions: Array<AllergenOption>
initialItem?: AdminMenuItem
submitLabel: string
onSubmit: (input: MenuItemInput) => Promise<void>
@@ -32,6 +37,7 @@ type MenuItemFormProps = {
export const MenuItemForm = ({
machineTags,
allergenOptions,
initialItem,
submitLabel,
onSubmit,
@@ -47,6 +53,9 @@ export const MenuItemForm = ({
toOptionRows(initialItem),
)
const [steps, setSteps] = useState<Array<StepRow>>(toStepRows(initialItem))
const [allergenIds, setAllergenIds] = useState<Array<number>>(
initialItem?.allergens.map((allergen) => allergen.id) ?? [],
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -58,6 +67,12 @@ export const MenuItemForm = ({
setSteps((rows) =>
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
)
const toggleAllergen = (allergenId: number) =>
setAllergenIds((ids) =>
ids.includes(allergenId)
? ids.filter((id) => id !== allergenId)
: [...ids, allergenId],
)
const submit = async (event: FormEvent) => {
event.preventDefault()
@@ -83,6 +98,7 @@ export const MenuItemForm = ({
machineTagId: Number(step.machineTagId),
estimatedDurationSeconds: Math.round(Number(step.durationSeconds)),
})),
allergenIds,
}
setSubmitting(true)
@@ -249,6 +265,24 @@ export const MenuItemForm = ({
</button>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<p className={styles.sectionTitle}>Allergens</p>
</div>
<div className={styles.allergenList}>
{allergenOptions.map((allergen) => (
<label key={allergen.id} className={styles.allergenOption}>
<input
type="checkbox"
checked={allergenIds.includes(allergen.id)}
onChange={() => toggleAllergen(allergen.id)}
/>
{allergen.name}
</label>
))}
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.actions}>
@@ -71,7 +71,11 @@ export const MenuItemCustomizer = ({
</div>
<div className={styles.stepperRow}>
<span className={styles.price}>{formatPrice(unitPriceCents)}</span>
<AnimatedNumber
value={unitPriceCents}
format={formatPrice}
className={styles.price}
/>
<div className={styles.stepper}>
<button
type="button"
@@ -25,3 +25,9 @@
color: $color-text-muted;
font-size: 0.9rem;
}
.allergens {
margin: $space-1 0 0;
color: $color-text-muted;
font-size: 0.85rem;
}
@@ -41,6 +41,12 @@ export const MenuItemDetail = ({
<p className={styles.meta}>
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p>
{item.allergens.length > 0 && (
<p className={styles.allergens}>
Contains:{' '}
{item.allergens.map((allergen) => allergen.name).join(', ')}
</p>
)}
</div>
<MenuItemCustomizer
@@ -68,6 +68,12 @@
color: $color-text-muted;
}
.allergens {
margin: $space-1 0 0;
font-size: 0.8rem;
color: $color-text-muted;
}
.quantity {
display: flex;
align-items: center;
+6
View File
@@ -46,6 +46,12 @@ export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
<p className={styles.meta}>
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
</p>
{item.allergens.length > 0 && (
<p className={styles.allergens}>
Contains:{' '}
{item.allergens.map((allergen) => allergen.name).join(', ')}
</p>
)}
</div>
</>
)
+32
View File
@@ -76,6 +76,38 @@ export const menuItemOptions = pgTable('menu_item_options', {
priceDeltaCents: integer('price_delta_cents').notNull().default(0),
})
// A shared catalog of allergens (e.g. "Gluten", "Dairy") that Menu Items
// draw from — unlike menuItemOptions, this list is reused across items
// rather than owned by any one of them.
export const allergens = pgTable(
'allergens',
{
id: serial().primaryKey(),
name: text().notNull(),
},
(table) => [uniqueIndex('allergens_name_idx').on(table.name)],
)
// Which Allergens a Menu Item's recipe contains, as ordered by an operator.
export const menuItemAllergens = pgTable(
'menu_item_allergens',
{
id: serial().primaryKey(),
menuItemId: integer('menu_item_id')
.notNull()
.references(() => menuItems.id),
allergenId: integer('allergen_id')
.notNull()
.references(() => allergens.id),
},
(table) => [
uniqueIndex('menu_item_allergens_item_allergen_idx').on(
table.menuItemId,
table.allergenId,
),
],
)
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
export type OrderStatus = (typeof orderStatusValues)[number]
+24
View File
@@ -145,6 +145,29 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
])
.returning()
const allergens = await db
.insert(schema.allergens)
.values([
{ name: 'Gluten' },
{ name: 'Dairy' },
{ name: 'Eggs' },
{ name: 'Soy' },
{ name: 'Peanuts' },
{ name: 'Tree nuts' },
{ name: 'Sesame' },
])
.returning()
const allergenByName = Object.fromEntries(
allergens.map((allergen) => [allergen.name, allergen]),
)
await db.insert(schema.menuItemAllergens).values([
{ menuItemId: pizza.id, allergenId: allergenByName['Gluten'].id },
{ menuItemId: pizza.id, allergenId: allergenByName['Dairy'].id },
{ menuItemId: burger.id, allergenId: allergenByName['Gluten'].id },
{ menuItemId: burger.id, allergenId: allergenByName['Sesame'].id },
])
return {
brand,
machineTags: {
@@ -156,6 +179,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
machines: { prep1, oven1, grill1, assembly1 },
menuItems: { pizza, burger },
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
allergens,
}
}
+53 -4
View File
@@ -26,6 +26,7 @@ export type AdminMenuItem = {
machineTagName: string
estimatedDurationSeconds: number
}>
allergens: Array<{ id: number; name: string }>
}
export type MenuItemInput = {
@@ -38,8 +39,19 @@ export type MenuItemInput = {
machineTagId: number
estimatedDurationSeconds: number
}>
allergenIds: Array<number>
}
export type AllergenOption = { id: number; name: string }
export const getAllergens = async (
db: Db,
): Promise<Array<AllergenOption>> =>
db
.select({ id: schema.allergens.id, name: schema.allergens.name })
.from(schema.allergens)
.orderBy(schema.allergens.name)
const assertHasSteps = (input: MenuItemInput) => {
if (input.steps.length === 0) {
throw new Error('A menu item needs at least one Preparation Step')
@@ -74,6 +86,18 @@ export const getMenuItemsForAdmin = async (
)
.orderBy(schema.menuItemSteps.sequence)
const itemAllergens = await db
.select({
menuItemId: schema.menuItemAllergens.menuItemId,
id: schema.allergens.id,
name: schema.allergens.name,
})
.from(schema.menuItemAllergens)
.innerJoin(
schema.allergens,
eq(schema.menuItemAllergens.allergenId, schema.allergens.id),
)
return items.map((item) => ({
id: item.id,
name: item.name,
@@ -89,6 +113,9 @@ export const getMenuItemsForAdmin = async (
steps: steps
.filter((step) => step.menuItemId === item.id)
.map(({ menuItemId: _menuItemId, ...step }) => step),
allergens: itemAllergens
.filter((allergen) => allergen.menuItemId === item.id)
.map(({ id, name }) => ({ id, name })),
}))
}
@@ -130,14 +157,24 @@ export const createMenuItem = async (
})),
)
if (input.allergenIds.length > 0) {
await tx.insert(schema.menuItemAllergens).values(
input.allergenIds.map((allergenId) => ({
menuItemId: item.id,
allergenId,
})),
)
}
return item.id
})
}
// Options and Steps are replaced wholesale on every edit rather than diffed
// — simplest correct behavior for a form that resubmits its full list each
// time, and safe because placed Order Items snapshot their own copy (see
// orderItemSteps/orderItemOptions) so this never rewrites order history.
// Options, Steps, and Allergens are replaced wholesale on every edit rather
// than diffed — simplest correct behavior for a form that resubmits its
// full list each time, and safe because placed Order Items snapshot their
// own copy (see orderItemSteps/orderItemOptions) so this never rewrites
// order history.
export const updateMenuItem = async (
db: Db,
menuItemId: number,
@@ -161,6 +198,9 @@ export const updateMenuItem = async (
await tx
.delete(schema.menuItemSteps)
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
await tx
.delete(schema.menuItemAllergens)
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
if (input.options.length > 0) {
await tx.insert(schema.menuItemOptions).values(
@@ -181,6 +221,12 @@ export const updateMenuItem = async (
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
)
if (input.allergenIds.length > 0) {
await tx.insert(schema.menuItemAllergens).values(
input.allergenIds.map((allergenId) => ({ menuItemId, allergenId })),
)
}
})
}
@@ -205,6 +251,9 @@ export const deleteMenuItem = async (
await tx
.delete(schema.menuItemSteps)
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
await tx
.delete(schema.menuItemAllergens)
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
// No ON DELETE CASCADE from orderItems.menuItemId — this throws (and
// rolls back the deletes above) if any Order ever referenced it.
await tx.delete(schema.menuItems).where(eq(schema.menuItems.id, menuItemId))
+23
View File
@@ -62,6 +62,10 @@ export type MenuItemWithRecipe = {
name: string
priceDeltaCents: number
}>
allergens: Array<{
id: number
name: string
}>
}
export const getMenu = async (
@@ -99,6 +103,22 @@ export const getMenu = async (
.from(schema.menuItemOptions)
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
const itemAllergens =
menuItemIds.length === 0
? []
: await db
.select({
menuItemId: schema.menuItemAllergens.menuItemId,
id: schema.allergens.id,
name: schema.allergens.name,
})
.from(schema.menuItemAllergens)
.innerJoin(
schema.allergens,
eq(schema.menuItemAllergens.allergenId, schema.allergens.id),
)
.where(inArray(schema.menuItemAllergens.menuItemId, menuItemIds))
return items.map((item) => ({
id: item.id,
name: item.name,
@@ -115,6 +135,9 @@ export const getMenu = async (
options: options
.filter((option) => option.menuItemId === item.id)
.map(({ id, name, priceDeltaCents }) => ({ id, name, priceDeltaCents })),
allergens: itemAllergens
.filter((allergen) => allergen.menuItemId === item.id)
.map(({ id, name }) => ({ id, name })),
}))
}
-10
View File
@@ -37,13 +37,3 @@
align-items: center;
gap: $space-2;
}
.helpLink {
color: $color-text-muted;
text-decoration: none;
font-size: 0.9rem;
&:hover {
color: $color-text;
}
}
-3
View File
@@ -34,9 +34,6 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => {
<Logo name={brand.name} />
</Link>
<div className={styles.actions}>
<Link to="/help" className={styles.helpLink}>
Help
</Link>
<BasketLink />
<BetterAuthHeader />
</div>
+15 -5
View File
@@ -2,10 +2,15 @@ import { createFileRoute, notFound, useNavigate } from '@tanstack/react-router'
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { getAdminMenu, getMachineTagOptions, updateMenuItem } from '#/server/menu-admin.ts'
import {
getAdminMenu,
getAllergenOptions,
getMachineTagOptions,
updateMenuItem,
} from '#/server/menu-admin.ts'
const EditMenuItemRoute = () => {
const { item, machineTags } = Route.useLoaderData()
const { item, machineTags, allergenOptions } = Route.useLoaderData()
const navigate = useNavigate()
return (
@@ -13,6 +18,7 @@ const EditMenuItemRoute = () => {
<h1>Edit {item.name}</h1>
<MenuItemForm
machineTags={machineTags}
allergenOptions={allergenOptions}
initialItem={item}
submitLabel="Save changes"
onSubmit={async (input) => {
@@ -26,12 +32,16 @@ const EditMenuItemRoute = () => {
export const Route = createFileRoute('/admin/menu/$menuItemId')({
loader: async ({ params }) => {
const [items, machineTags] = await withAdminGuard(() =>
Promise.all([getAdminMenu(), getMachineTagOptions()]),
const [items, machineTags, allergenOptions] = await withAdminGuard(() =>
Promise.all([
getAdminMenu(),
getMachineTagOptions(),
getAllergenOptions(),
]),
)
const item = items.find((i) => i.id === Number(params.menuItemId))
if (!item) throw notFound()
return { item, machineTags }
return { item, machineTags, allergenOptions }
},
component: EditMenuItemRoute,
})
+12 -3
View File
@@ -2,10 +2,14 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { createMenuItem, getMachineTagOptions } from '#/server/menu-admin.ts'
import {
createMenuItem,
getAllergenOptions,
getMachineTagOptions,
} from '#/server/menu-admin.ts'
const NewMenuItemRoute = () => {
const machineTags = Route.useLoaderData()
const { machineTags, allergenOptions } = Route.useLoaderData()
const navigate = useNavigate()
return (
@@ -13,6 +17,7 @@ const NewMenuItemRoute = () => {
<h1>Add menu item</h1>
<MenuItemForm
machineTags={machineTags}
allergenOptions={allergenOptions}
submitLabel="Create item"
onSubmit={async (input) => {
await createMenuItem({ data: input })
@@ -24,6 +29,10 @@ const NewMenuItemRoute = () => {
}
export const Route = createFileRoute('/admin/menu/new')({
loader: () => withAdminGuard(() => getMachineTagOptions()),
loader: () =>
withAdminGuard(async () => ({
machineTags: await getMachineTagOptions(),
allergenOptions: await getAllergenOptions(),
})),
component: NewMenuItemRoute,
})
+8
View File
@@ -21,6 +21,7 @@ const menuItemInputSchema = z.object({
estimatedDurationSeconds: z.number().int().positive(),
}),
),
allergenIds: z.array(z.number()),
})
export const getAdminMenu = createServerFn({ method: 'GET' }).handler(
@@ -38,6 +39,13 @@ export const getMachineTagOptions = createServerFn({ method: 'GET' }).handler(
},
)
export const getAllergenOptions = createServerFn({ method: 'GET' }).handler(
async () => {
await requireAdmin()
return menu.getAllergens(db)
},
)
export const createMenuItem = createServerFn({ method: 'POST' })
.validator(menuItemInputSchema)
.handler(async ({ data }) => {