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 = ({ +
+
+

Allergens

+
+
+ {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 }) => {