From 8481032e30f31d6408858fcab01961f009af10b5 Mon Sep 17 00:00:00 2001
From: lukas
Date: Thu, 17 Sep 2026 11:50:17 +0200
Subject: [PATCH] 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
---
drizzle/0006_worried_shooting_star.sql | 15 +
drizzle/meta/0006_snapshot.json | 870 ++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/components/admin/AdminMenuListView.tsx | 3 +
src/components/admin/MenuItemForm.module.scss | 15 +
src/components/admin/MenuItemForm.tsx | 36 +-
.../ordering/MenuItemCustomizer.tsx | 6 +-
.../ordering/MenuItemDetail.module.scss | 6 +
src/components/ordering/MenuItemDetail.tsx | 6 +
src/components/ordering/MenuList.module.scss | 6 +
src/components/ordering/MenuList.tsx | 6 +
src/db/schema.ts | 32 +
src/db/seed.ts | 24 +
src/domain/menu.ts | 57 +-
src/domain/ordering.ts | 23 +
src/routes/__root.module.scss | 10 -
src/routes/__root.tsx | 3 -
src/routes/admin/menu/$menuItemId.tsx | 20 +-
src/routes/admin/menu/new.tsx | 15 +-
src/server/menu-admin.ts | 8 +
20 files changed, 1141 insertions(+), 27 deletions(-)
create mode 100644 drizzle/0006_worried_shooting_star.sql
create mode 100644 drizzle/meta/0006_snapshot.json
diff --git a/drizzle/0006_worried_shooting_star.sql b/drizzle/0006_worried_shooting_star.sql
new file mode 100644
index 0000000..cd11a97
--- /dev/null
+++ b/drizzle/0006_worried_shooting_star.sql
@@ -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");
\ No newline at end of file
diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json
new file mode 100644
index 0000000..e1e0d17
--- /dev/null
+++ b/drizzle/meta/0006_snapshot.json
@@ -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": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 994218d..89b016e 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/src/components/admin/AdminMenuListView.tsx b/src/components/admin/AdminMenuListView.tsx
index fd4bf4e..07b774e 100644
--- a/src/components/admin/AdminMenuListView.tsx
+++ b/src/components/admin/AdminMenuListView.tsx
@@ -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}` : ''}
diff --git a/src/components/admin/MenuItemForm.module.scss b/src/components/admin/MenuItemForm.module.scss
index ebda6d9..773dcc1 100644
--- a/src/components/admin/MenuItemForm.module.scss
+++ b/src/components/admin/MenuItemForm.module.scss
@@ -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;
diff --git a/src/components/admin/MenuItemForm.tsx b/src/components/admin/MenuItemForm.tsx
index 1586192..f05b3b9 100644
--- a/src/components/admin/MenuItemForm.tsx
+++ b/src/components/admin/MenuItemForm.tsx
@@ -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 =>
type MenuItemFormProps = {
machineTags: Array
+ allergenOptions: Array
initialItem?: AdminMenuItem
submitLabel: string
onSubmit: (input: MenuItemInput) => Promise
@@ -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>(toStepRows(initialItem))
+ const [allergenIds, setAllergenIds] = useState>(
+ initialItem?.allergens.map((allergen) => allergen.id) ?? [],
+ )
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(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 = ({
+
+
+
+ {allergenOptions.map((allergen) => (
+
+ ))}
+
+
+
{error && {error}
}
diff --git a/src/components/ordering/MenuItemCustomizer.tsx b/src/components/ordering/MenuItemCustomizer.tsx
index f467b07..f941626 100644
--- a/src/components/ordering/MenuItemCustomizer.tsx
+++ b/src/components/ordering/MenuItemCustomizer.tsx
@@ -71,7 +71,11 @@ export const MenuItemCustomizer = ({
-
{formatPrice(unitPriceCents)}
+
(
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
+ {item.allergens.length > 0 && (
+
+ Contains:{' '}
+ {item.allergens.map((allergen) => allergen.name).join(', ')}
+
+ )}
>
)
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 91fa12a..6a74bfb 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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]
diff --git a/src/db/seed.ts b/src/db/seed.ts
index 10e217d..84dad61 100644
--- a/src/db/seed.ts
+++ b/src/db/seed.ts
@@ -145,6 +145,29 @@ export const seedGhostKitchen = async (db: NodePgDatabase) => {
])
.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) => {
machines: { prep1, oven1, grill1, assembly1 },
menuItems: { pizza, burger },
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
+ allergens,
}
}
diff --git a/src/domain/menu.ts b/src/domain/menu.ts
index af77c4e..3d251d5 100644
--- a/src/domain/menu.ts
+++ b/src/domain/menu.ts
@@ -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
}
+export type AllergenOption = { id: number; name: string }
+
+export const getAllergens = async (
+ db: Db,
+): Promise> =>
+ 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))
diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts
index 6961937..bc0a65c 100644
--- a/src/domain/ordering.ts
+++ b/src/domain/ordering.ts
@@ -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 })),
}))
}
diff --git a/src/routes/__root.module.scss b/src/routes/__root.module.scss
index ff824b0..e74f9a9 100644
--- a/src/routes/__root.module.scss
+++ b/src/routes/__root.module.scss
@@ -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;
- }
-}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 10ef2fb..64a8ee3 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -34,9 +34,6 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => {
-
- Help
-
diff --git a/src/routes/admin/menu/$menuItemId.tsx b/src/routes/admin/menu/$menuItemId.tsx
index bafbaeb..ba4c5a5 100644
--- a/src/routes/admin/menu/$menuItemId.tsx
+++ b/src/routes/admin/menu/$menuItemId.tsx
@@ -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 = () => {
Edit {item.name}
{
@@ -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,
})
diff --git a/src/routes/admin/menu/new.tsx b/src/routes/admin/menu/new.tsx
index dc5f1a1..a18a56c 100644
--- a/src/routes/admin/menu/new.tsx
+++ b/src/routes/admin/menu/new.tsx
@@ -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 = () => {
Add menu item
{
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,
})
diff --git a/src/server/menu-admin.ts b/src/server/menu-admin.ts
index d698330..84e8b2a 100644
--- a/src/server/menu-admin.ts
+++ b/src/server/menu-admin.ts
@@ -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 }) => {