Add menu item customization/detail pages and configurable currency formatting
Lets customers pick options on a menu item detail page before adding to the basket, and makes displayed prices respect a configurable currency/locale via VITE_CURRENCY/VITE_LOCALE. Also crossfades the basket's empty/filled states with AnimatePresence in wait mode instead of animating both at once. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,12 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5433/ghost_kitchen
|
|||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
|
# ISO 4217 currency code this Kitchen charges and displays prices in, and
|
||||||
|
# the BCP 47 locale used to format it (decimal separator, digit grouping).
|
||||||
|
# Default to EUR / de-DE (this deployment's location) when unset.
|
||||||
|
VITE_CURRENCY=EUR
|
||||||
|
VITE_LOCALE=de-DE
|
||||||
|
|
||||||
# Optional: enables "Continue with Google" / "Continue with Apple" on the
|
# Optional: enables "Continue with Google" / "Continue with Apple" on the
|
||||||
# sign-in and sign-up pages. Leave unset to fall back to email/password only.
|
# sign-in and sign-up pages. Leave unset to fall back to email/password only.
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "menu_item_options" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"menu_item_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"price_delta_cents" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "order_item_options" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order_item_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"price_delta_cents" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "menu_item_options" ADD CONSTRAINT "menu_item_options_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "order_item_options" ADD CONSTRAINT "order_item_options_order_item_id_order_items_id_fk" FOREIGN KEY ("order_item_id") REFERENCES "public"."order_items"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
{
|
||||||
|
"id": "cd474707-7446-42df-afd9-44d66a9a03a2",
|
||||||
|
"prevId": "3761e215-1f2d-4374-83af-942ef92e41df",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.brands": {
|
||||||
|
"name": "brands",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.machine_tags": {
|
||||||
|
"name": "machine_tags",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"machine_tags_name_idx": {
|
||||||
|
"name": "machine_tags_name_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "name",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.machines": {
|
||||||
|
"name": "machines",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"machines_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "machines_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "machines",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_steps": {
|
||||||
|
"name": "menu_item_steps",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"name": "sequence",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"estimated_duration_seconds": {
|
||||||
|
"name": "estimated_duration_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_steps_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_steps",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "menu_item_steps",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_items": {
|
||||||
|
"name": "menu_items",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"brand_id": {
|
||||||
|
"name": "brand_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_items_brand_id_brands_id_fk": {
|
||||||
|
"name": "menu_items_brand_id_brands_id_fk",
|
||||||
|
"tableFrom": "menu_items",
|
||||||
|
"tableTo": "brands",
|
||||||
|
"columnsFrom": [
|
||||||
|
"brand_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.order_item_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.order_item_steps": {
|
||||||
|
"name": "order_item_steps",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"name": "sequence",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"estimated_duration_seconds": {
|
||||||
|
"name": "estimated_duration_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_steps_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_steps_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_steps",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "order_item_steps",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.order_items": {
|
||||||
|
"name": "order_items",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_id": {
|
||||||
|
"name": "order_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'placed'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_items_order_id_orders_id_fk": {
|
||||||
|
"name": "order_items_order_id_orders_id_fk",
|
||||||
|
"tableFrom": "order_items",
|
||||||
|
"tableTo": "orders",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"order_items_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "order_items_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "order_items",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.orders": {
|
||||||
|
"name": "orders",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_id": {
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"guest_name": {
|
||||||
|
"name": "guest_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"guest_contact": {
|
||||||
|
"name": "guest_contact",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"guest_access_token": {
|
||||||
|
"name": "guest_access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'placed'"
|
||||||
|
},
|
||||||
|
"payment_charge_id": {
|
||||||
|
"name": "payment_charge_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.preparation_step_instances": {
|
||||||
|
"name": "preparation_step_instances",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"name": "sequence",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"estimated_duration_seconds": {
|
||||||
|
"name": "estimated_duration_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"machine_id": {
|
||||||
|
"name": "machine_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"done_at": {
|
||||||
|
"name": "done_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"preparation_step_instances_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "preparation_step_instances",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "preparation_step_instances",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"preparation_step_instances_machine_id_machines_id_fk": {
|
||||||
|
"name": "preparation_step_instances_machine_id_machines_id_fk",
|
||||||
|
"tableFrom": "preparation_step_instances",
|
||||||
|
"tableTo": "machines",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1789028563859,
|
"when": 1789028563859,
|
||||||
"tag": "0003_soft_psynapse",
|
"tag": "0003_soft_psynapse",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789036823859,
|
||||||
|
"tag": "0004_misty_hedge_knight",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -26,7 +26,7 @@
|
|||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-surface;
|
background: $color-border;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import styles from './BasketLink.module.scss'
|
|||||||
|
|
||||||
const MotionLink = motion.create(Link)
|
const MotionLink = motion.create(Link)
|
||||||
|
|
||||||
const BasketIcon = () => (
|
export const BasketIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
color: $color-primary-contrast;
|
color: $color-primary-contrast;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Customer =
|
|||||||
type PlacedOrder = { id: number; guestAccessToken: string | null }
|
type PlacedOrder = { id: number; guestAccessToken: string | null }
|
||||||
|
|
||||||
type CheckoutSectionProps = {
|
type CheckoutSectionProps = {
|
||||||
items: Array<{ menuItemId: number }>
|
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onPlaced: (order: PlacedOrder) => void
|
onPlaced: (order: PlacedOrder) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.customizer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
padding: $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
span:first-of-type {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta {
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepperRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: $space-3;
|
||||||
|
border-top: 1px solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepperButton {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
background: $color-background;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
min-width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||||
|
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
import type { BasketLine } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice, formatPriceDelta } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './MenuItemCustomizer.module.scss'
|
||||||
|
|
||||||
|
type MenuItemCustomizerProps = {
|
||||||
|
item: MenuItemWithRecipe
|
||||||
|
// Every basket line for this item, including the plain no-options one —
|
||||||
|
// the stepper below tracks whichever combination is currently checked.
|
||||||
|
lines: Array<BasketLine>
|
||||||
|
onAdd: (menuItemId: number, optionIds: Array<number>) => void
|
||||||
|
onRemove: (menuItemId: number, optionIds: Array<number>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedKey = (optionIds: Array<number>) =>
|
||||||
|
[...optionIds].sort((a, b) => a - b).join(',')
|
||||||
|
|
||||||
|
export const MenuItemCustomizer = ({
|
||||||
|
item,
|
||||||
|
lines,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: MenuItemCustomizerProps) => {
|
||||||
|
const [checkedOptionIds, setCheckedOptionIds] = useState<Array<number>>([])
|
||||||
|
|
||||||
|
const toggleOption = (optionId: number) =>
|
||||||
|
setCheckedOptionIds((prev) =>
|
||||||
|
prev.includes(optionId)
|
||||||
|
? prev.filter((id) => id !== optionId)
|
||||||
|
: [...prev, optionId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// The one stepper on this page always reflects whatever combination of
|
||||||
|
// options is currently checked — checking different boxes just points it
|
||||||
|
// at a different (possibly already-existing) basket line.
|
||||||
|
const currentLine = lines.find(
|
||||||
|
(line) => sortedKey(line.optionIds) === sortedKey(checkedOptionIds),
|
||||||
|
)
|
||||||
|
const quantity = currentLine?.quantity ?? 0
|
||||||
|
const unitPriceCents =
|
||||||
|
item.priceCents +
|
||||||
|
checkedOptionIds.reduce(
|
||||||
|
(sum, id) =>
|
||||||
|
sum + (item.options.find((option) => option.id === id)?.priceDeltaCents ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.customizer}>
|
||||||
|
<h2 className={styles.heading}>Customize</h2>
|
||||||
|
<div className={styles.panel}>
|
||||||
|
{item.options.map((option) => (
|
||||||
|
<label key={option.id} className={styles.option}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedOptionIds.includes(option.id)}
|
||||||
|
onChange={() => toggleOption(option.id)}
|
||||||
|
/>
|
||||||
|
<span>{option.name}</span>
|
||||||
|
{formatPriceDelta(option.priceDeltaCents) && (
|
||||||
|
<span className={styles.delta}>
|
||||||
|
{formatPriceDelta(option.priceDeltaCents)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.stepperRow}>
|
||||||
|
<span className={styles.price}>{formatPrice(unitPriceCents)}</span>
|
||||||
|
<div className={styles.stepper}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.stepperButton}
|
||||||
|
onClick={() => onRemove(item.id, checkedOptionIds)}
|
||||||
|
disabled={quantity === 0}
|
||||||
|
aria-label={`Remove one ${item.name}`}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<AnimatedNumber value={quantity} className={styles.count} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.stepperButton}
|
||||||
|
onClick={() => onAdd(item.id, checkedOptionIds)}
|
||||||
|
aria-label={`Add one ${item.name}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { MenuItemCustomizer } from '#/components/ordering/MenuItemCustomizer.tsx'
|
||||||
|
import { formatDuration } from '#/components/ordering/MenuList.tsx'
|
||||||
|
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
|
||||||
|
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
import type { BasketLine } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './MenuItemDetail.module.scss'
|
||||||
|
|
||||||
|
type MenuItemDetailProps = {
|
||||||
|
item: MenuItemWithRecipe
|
||||||
|
lines: Array<BasketLine>
|
||||||
|
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItemDetail = ({
|
||||||
|
item,
|
||||||
|
lines,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: MenuItemDetailProps) => {
|
||||||
|
const totalSeconds = item.steps.reduce(
|
||||||
|
(sum, step) => sum + step.estimatedDurationSeconds,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<MenuItemThumbnail
|
||||||
|
name={item.name}
|
||||||
|
size="hero"
|
||||||
|
style={{ viewTransitionName: `menu-item-${item.id}` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.info}>
|
||||||
|
<h1 className={styles.name}>{item.name}</h1>
|
||||||
|
{item.description && (
|
||||||
|
<p className={styles.description}>{item.description}</p>
|
||||||
|
)}
|
||||||
|
<p className={styles.meta}>
|
||||||
|
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MenuItemCustomizer
|
||||||
|
item={item}
|
||||||
|
lines={lines}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: $radius;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $color-background;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 1.75rem;
|
||||||
|
width: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
import styles from './MenuItemThumbnail.module.scss'
|
||||||
|
|
||||||
|
type MenuItemThumbnailProps = {
|
||||||
|
name: string
|
||||||
|
size?: 'small' | 'hero'
|
||||||
|
style?: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const UtensilsIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M7 3v7a2 2 0 0 0 2 2v9" />
|
||||||
|
<path d="M7 3v4M11 3v4" />
|
||||||
|
<path d="M17 3c-1.5 0-2.5 2-2.5 5s1 5 1.5 5v9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const photoByKeyword: Array<{ keyword: string; src: string }> = [
|
||||||
|
{ keyword: 'pizza', src: '/pizza.png' },
|
||||||
|
{ keyword: 'burger', src: '/burger.png' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Falls back to a simple icon for any menu item that isn't Pizza or Burger,
|
||||||
|
// since there's no photo for it yet.
|
||||||
|
const photoFor = (name: string) => {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
return photoByKeyword.find((entry) => lower.includes(entry.keyword))?.src
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItemThumbnail = ({
|
||||||
|
name,
|
||||||
|
size = 'small',
|
||||||
|
style,
|
||||||
|
}: MenuItemThumbnailProps) => {
|
||||||
|
const src = photoFor(name)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.thumbnail} ${size === 'hero' ? styles.hero : ''}`}
|
||||||
|
style={style}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt="" className={styles.photo} />
|
||||||
|
) : (
|
||||||
|
<UtensilsIcon />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,16 +10,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $space-3;
|
|
||||||
padding: $space-3;
|
padding: $space-3;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
background: $color-surface;
|
background: $color-surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-3;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:is(a, button) {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover .name {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -27,6 +53,7 @@
|
|||||||
.name {
|
.name {
|
||||||
margin: 0 0 $space-1;
|
margin: 0 0 $space-1;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
@@ -56,6 +83,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { MenuItemThumbnail } from '#/components/ordering/MenuItemThumbnail.tsx'
|
||||||
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||||
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
import type { BasketLine } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
import styles from './MenuList.module.scss'
|
import styles from './MenuList.module.scss'
|
||||||
|
|
||||||
type MenuListProps = {
|
type MenuListProps = {
|
||||||
menu: Array<MenuItemWithRecipe>
|
menu: Array<MenuItemWithRecipe>
|
||||||
quantities: Record<number, number>
|
lines: Array<BasketLine>
|
||||||
onAdd: (menuItemId: number) => void
|
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
onRemove: (menuItemId: number) => void
|
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
export const formatDuration = (seconds: number) => {
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
|
||||||
const minutes = Math.round(seconds / 60)
|
const minutes = Math.round(seconds / 60)
|
||||||
return minutes <= 1 ? '~1 min' : `~${minutes} min`
|
return minutes <= 1 ? '1 min' : `${minutes} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MenuList = ({
|
export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
|
||||||
menu,
|
|
||||||
quantities,
|
|
||||||
onAdd,
|
|
||||||
onRemove,
|
|
||||||
}: MenuListProps) => (
|
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{menu.map((item) => {
|
{menu.map((item) => {
|
||||||
const quantity = quantities[item.id] ?? 0
|
const defaultLine = lines.find(
|
||||||
|
(line) => line.menuItemId === item.id && line.optionIds.length === 0,
|
||||||
|
)
|
||||||
|
const hasOptions = item.options.length > 0
|
||||||
const totalSeconds = item.steps.reduce(
|
const totalSeconds = item.steps.reduce(
|
||||||
(sum, step) => sum + step.estimatedDurationSeconds,
|
(sum, step) => sum + step.estimatedDurationSeconds,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const info = (
|
||||||
<li key={item.id} className={styles.item}>
|
<>
|
||||||
|
<MenuItemThumbnail
|
||||||
|
name={item.name}
|
||||||
|
style={{ viewTransitionName: `menu-item-${item.id}` }}
|
||||||
|
/>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<h3 className={styles.name}>{item.name}</h3>
|
<h3 className={styles.name}>{item.name}</h3>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
@@ -42,17 +47,37 @@ export const MenuList = ({
|
|||||||
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id} className={styles.item}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
{hasOptions ? (
|
||||||
|
<Link
|
||||||
|
to="/items/$menuItemId"
|
||||||
|
params={{ menuItemId: String(item.id) }}
|
||||||
|
className={styles.main}
|
||||||
|
>
|
||||||
|
{info}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={styles.main}>{info}</div>
|
||||||
|
)}
|
||||||
<div className={styles.quantity}>
|
<div className={styles.quantity}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
className={styles.stepperButton}
|
||||||
onClick={() => onRemove(item.id)}
|
onClick={() => onRemove(item.id)}
|
||||||
disabled={quantity === 0}
|
disabled={(defaultLine?.quantity ?? 0) === 0}
|
||||||
aria-label={`Remove one ${item.name}`}
|
aria-label={`Remove one ${item.name}`}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
<AnimatedNumber value={quantity} className={styles.count} />
|
<AnimatedNumber
|
||||||
|
value={defaultLine?.quantity ?? 0}
|
||||||
|
className={styles.count}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
className={styles.stepperButton}
|
||||||
@@ -62,6 +87,7 @@ export const MenuList = ({
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
@use '../../styles/variables' as *;
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
@position-try --cancel-hint-flip {
|
||||||
|
position-area: bottom;
|
||||||
|
--hint-arrow-top: auto;
|
||||||
|
--hint-arrow-bottom: 100%;
|
||||||
|
--hint-arrow-color-top: transparent;
|
||||||
|
--hint-arrow-color-bottom: #{$color-text};
|
||||||
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -36,11 +44,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.orderStatus {
|
.orderStatus {
|
||||||
margin: 0 0 $space-4;
|
margin: 0 0 $space-1;
|
||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orderDate {
|
||||||
|
margin: 0 0 $space-4;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: $color-danger;
|
color: $color-danger;
|
||||||
}
|
}
|
||||||
@@ -73,6 +87,12 @@
|
|||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.itemOptions {
|
||||||
|
margin: 0 0 $space-2;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
margin: 0 0 $space-2;
|
margin: 0 0 $space-2;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -194,6 +214,7 @@
|
|||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: $space-2;
|
gap: $space-2;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
@@ -206,6 +227,11 @@
|
|||||||
color: $color-text;
|
color: $color-text;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -213,6 +239,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: $space-1;
|
||||||
|
padding: $space-1 $space-2;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: $color-primary;
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoIcon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelHint {
|
||||||
|
position: fixed;
|
||||||
|
position-area: top;
|
||||||
|
position-try-fallbacks: --cancel-hint-flip;
|
||||||
|
inset: auto;
|
||||||
|
overflow: visible;
|
||||||
|
height: auto;
|
||||||
|
margin: $space-2;
|
||||||
|
padding: $space-2 $space-3;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 260px;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-text;
|
||||||
|
color: $color-background;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
--hint-arrow-top: 100%;
|
||||||
|
--hint-arrow-bottom: auto;
|
||||||
|
--hint-arrow-color-top: #{$color-text};
|
||||||
|
--hint-arrow-color-bottom: transparent;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: var(--hint-arrow-top);
|
||||||
|
bottom: var(--hint-arrow-bottom);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: var(--hint-arrow-color-top);
|
||||||
|
border-bottom-color: var(--hint-arrow-color-bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cancelButton {
|
.cancelButton {
|
||||||
padding: $space-1 $space-2;
|
padding: $space-1 $space-2;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ const helpMailto = (orderId: number, item: OrderItemStatusView) => {
|
|||||||
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
|
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelBlockedReason = (item: OrderItemStatusView) => {
|
||||||
|
if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (item.status === 'ready') {
|
||||||
|
return "This item is already done cooking, so it's too late to cancel. If something's wrong with it, use Get help below."
|
||||||
|
}
|
||||||
|
return "This item has already started cooking, so the kitchen can't stop or undo it. If something's wrong, use Get help below."
|
||||||
|
}
|
||||||
|
|
||||||
const BackArrowIcon = () => (
|
const BackArrowIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className={styles.backArrow}
|
className={styles.backArrow}
|
||||||
@@ -32,9 +42,27 @@ const BackArrowIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const InfoIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.infoIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<line x1="12" y1="11" x2="12" y2="16" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
export type OrderStatusResult = {
|
export type OrderStatusResult = {
|
||||||
id: number
|
id: number
|
||||||
status: OrderStatus
|
status: OrderStatus
|
||||||
|
createdAt: Date
|
||||||
items: Array<OrderItemStatusView>
|
items: Array<OrderItemStatusView>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,15 +101,34 @@ export const OrderStatusView = ({
|
|||||||
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
||||||
<h1 className={styles.heading}>Order #{status.id}</h1>
|
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||||
<p className={styles.orderStatus}>Status: {status.status}</p>
|
<p className={styles.orderStatus}>Status: {status.status}</p>
|
||||||
|
<p className={styles.orderDate}>
|
||||||
|
Placed{' '}
|
||||||
|
{status.createdAt.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
{' at '}
|
||||||
|
{status.createdAt.toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
<ul className={styles.items}>
|
<ul className={styles.items}>
|
||||||
{status.items.map((item) => (
|
{status.items.map((item) => {
|
||||||
|
const cancelHint = cancelBlockedReason(item)
|
||||||
|
return (
|
||||||
<li key={item.id} className={styles.item}>
|
<li key={item.id} className={styles.item}>
|
||||||
<div className={styles.itemHeader}>
|
<div className={styles.itemHeader}>
|
||||||
<span className={styles.itemName}>{item.menuItemName}</span>
|
<span className={styles.itemName}>{item.menuItemName}</span>
|
||||||
<span className={styles.itemStatus}>{item.status}</span>
|
<span className={styles.itemStatus}>{item.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{item.options.length > 0 && (
|
||||||
|
<p className={styles.itemOptions}>
|
||||||
|
{item.options.map((option) => option.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<ol className={styles.timeline}>
|
<ol className={styles.timeline}>
|
||||||
{item.steps.map((step) => (
|
{item.steps.map((step) => (
|
||||||
<li
|
<li
|
||||||
@@ -118,7 +165,6 @@ export const OrderStatusView = ({
|
|||||||
Buy again
|
Buy again
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
{item.status === 'ready' && (
|
{item.status === 'ready' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,6 +181,28 @@ export const OrderStatusView = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a href={helpMailto(status.id, item)}>Get help</a>
|
<a href={helpMailto(status.id, item)}>Get help</a>
|
||||||
|
{cancelHint && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.infoButton}
|
||||||
|
popoverTarget={`cancel-hint-${item.id}`}
|
||||||
|
style={{ anchorName: `--cancel-hint-anchor-${item.id}` }}
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
Why can't I cancel?
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
id={`cancel-hint-${item.id}`}
|
||||||
|
popover="auto"
|
||||||
|
role="tooltip"
|
||||||
|
className={styles.cancelHint}
|
||||||
|
style={{ positionAnchor: `--cancel-hint-anchor-${item.id}` }}
|
||||||
|
>
|
||||||
|
{cancelHint}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{item.cancellable && (
|
{item.cancellable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,7 +221,8 @@ export const OrderStatusView = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
import styles from './OrdersListView.module.scss'
|
import styles from './OrdersListView.module.scss'
|
||||||
|
|
||||||
@@ -8,8 +9,6 @@ type OrdersListViewProps = {
|
|||||||
orders: Array<CustomerOrderSummary>
|
orders: Array<CustomerOrderSummary>
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
|
||||||
|
|
||||||
const formatItems = (items: CustomerOrderSummary['items']) =>
|
const formatItems = (items: CustomerOrderSummary['items']) =>
|
||||||
items
|
items
|
||||||
.map(({ menuItemName, quantity }) =>
|
.map(({ menuItemName, quantity }) =>
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ export const menuItemSteps = pgTable('menu_item_steps', {
|
|||||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A customization a customer can toggle onto a Menu Item at order time,
|
||||||
|
// e.g. "Extra cheese" (+150 cents) or "No onions" (no price change).
|
||||||
|
export const menuItemOptions = pgTable('menu_item_options', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
menuItemId: integer('menu_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => menuItems.id),
|
||||||
|
name: text().notNull(),
|
||||||
|
priceDeltaCents: integer('price_delta_cents').notNull().default(0),
|
||||||
|
})
|
||||||
|
|
||||||
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
|
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
|
||||||
export type OrderStatus = (typeof orderStatusValues)[number]
|
export type OrderStatus = (typeof orderStatusValues)[number]
|
||||||
|
|
||||||
@@ -123,6 +134,18 @@ export const orderItemSteps = pgTable('order_item_steps', {
|
|||||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A frozen copy of the Menu Item Options selected for a given Order Item —
|
||||||
|
// same reasoning as orderItemSteps: a later edit to the live option catalog
|
||||||
|
// (menuItemOptions) never rewrites an Order already placed.
|
||||||
|
export const orderItemOptions = pgTable('order_item_options', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
orderItemId: integer('order_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => orderItems.id),
|
||||||
|
name: text().notNull(),
|
||||||
|
priceDeltaCents: integer('price_delta_cents').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export const preparationStepStatusValues = [
|
export const preparationStepStatusValues = [
|
||||||
'queued',
|
'queued',
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const pizzaOptions = await db
|
||||||
|
.insert(schema.menuItemOptions)
|
||||||
|
.values([
|
||||||
|
{ menuItemId: pizza.id, name: 'Extra cheese', priceDeltaCents: 150 },
|
||||||
|
{ menuItemId: pizza.id, name: 'No onions', priceDeltaCents: 0 },
|
||||||
|
{ menuItemId: pizza.id, name: 'Extra sauce', priceDeltaCents: 0 },
|
||||||
|
])
|
||||||
|
.returning()
|
||||||
|
|
||||||
const [burger] = await db
|
const [burger] = await db
|
||||||
.insert(schema.menuItems)
|
.insert(schema.menuItems)
|
||||||
.values({
|
.values({
|
||||||
@@ -127,6 +136,15 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const burgerOptions = await db
|
||||||
|
.insert(schema.menuItemOptions)
|
||||||
|
.values([
|
||||||
|
{ menuItemId: burger.id, name: 'Extra cheese', priceDeltaCents: 100 },
|
||||||
|
{ menuItemId: burger.id, name: 'No pickles', priceDeltaCents: 0 },
|
||||||
|
{ menuItemId: burger.id, name: 'Extra patty', priceDeltaCents: 250 },
|
||||||
|
])
|
||||||
|
.returning()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand,
|
brand,
|
||||||
machineTags: {
|
machineTags: {
|
||||||
@@ -137,6 +155,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
},
|
},
|
||||||
machines: { prep1, oven1, grill1, assembly1 },
|
machines: { prep1, oven1, grill1, assembly1 },
|
||||||
menuItems: { pizza, burger },
|
menuItems: { pizza, burger },
|
||||||
|
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+80
-14
@@ -57,6 +57,11 @@ export type MenuItemWithRecipe = {
|
|||||||
machineTag: string
|
machineTag: string
|
||||||
estimatedDurationSeconds: number
|
estimatedDurationSeconds: number
|
||||||
}>
|
}>
|
||||||
|
options: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
priceDeltaCents: number
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMenu = async (
|
export const getMenu = async (
|
||||||
@@ -68,6 +73,8 @@ export const getMenu = async (
|
|||||||
.from(schema.menuItems)
|
.from(schema.menuItems)
|
||||||
.where(eq(schema.menuItems.brandId, brandId))
|
.where(eq(schema.menuItems.brandId, brandId))
|
||||||
|
|
||||||
|
const menuItemIds = items.map((item) => item.id)
|
||||||
|
|
||||||
const steps = await db
|
const steps = await db
|
||||||
.select({
|
.select({
|
||||||
menuItemId: schema.menuItemSteps.menuItemId,
|
menuItemId: schema.menuItemSteps.menuItemId,
|
||||||
@@ -81,14 +88,17 @@ export const getMenu = async (
|
|||||||
schema.machineTags,
|
schema.machineTags,
|
||||||
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
||||||
)
|
)
|
||||||
.where(
|
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
|
||||||
inArray(
|
|
||||||
schema.menuItemSteps.menuItemId,
|
|
||||||
items.map((item) => item.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(schema.menuItemSteps.sequence))
|
.orderBy(asc(schema.menuItemSteps.sequence))
|
||||||
|
|
||||||
|
const options =
|
||||||
|
menuItemIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(schema.menuItemOptions)
|
||||||
|
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
|
||||||
|
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -102,6 +112,9 @@ export const getMenu = async (
|
|||||||
machineTag,
|
machineTag,
|
||||||
estimatedDurationSeconds,
|
estimatedDurationSeconds,
|
||||||
})),
|
})),
|
||||||
|
options: options
|
||||||
|
.filter((option) => option.menuItemId === item.id)
|
||||||
|
.map(({ id, name, priceDeltaCents }) => ({ id, name, priceDeltaCents })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +244,7 @@ const initializeOrderItemRecipe = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PlaceOrderInput = {
|
export type PlaceOrderInput = {
|
||||||
items: Array<{ menuItemId: number }>
|
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
|
||||||
customer:
|
customer:
|
||||||
| { type: 'guest'; name: string; contact: string }
|
| { type: 'guest'; name: string; contact: string }
|
||||||
| { type: 'account'; customerId: string }
|
| { type: 'account'; customerId: string }
|
||||||
@@ -276,8 +289,39 @@ export const placeOrder = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCents = input.items.reduce(
|
const allSelectedOptionIds = [
|
||||||
(sum, item) => sum + menuItemById.get(item.menuItemId)!.priceCents,
|
...new Set(input.items.flatMap((item) => item.selectedOptionIds ?? [])),
|
||||||
|
]
|
||||||
|
const selectedOptionRows =
|
||||||
|
allSelectedOptionIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(schema.menuItemOptions)
|
||||||
|
.where(inArray(schema.menuItemOptions.id, allSelectedOptionIds))
|
||||||
|
const optionById = new Map(selectedOptionRows.map((row) => [row.id, row]))
|
||||||
|
|
||||||
|
// Resolve each Order Item's selected Options up front, validating that
|
||||||
|
// every id is a real Option belonging to that specific Menu Item — not,
|
||||||
|
// say, a Burger option applied to a Pizza — before anyone is charged.
|
||||||
|
const resolvedItems = input.items.map((item) => {
|
||||||
|
const options = (item.selectedOptionIds ?? []).map((optionId) => {
|
||||||
|
const option = optionById.get(optionId)
|
||||||
|
if (!option || option.menuItemId !== item.menuItemId) {
|
||||||
|
throw new Error(
|
||||||
|
`Option ${optionId} is not valid for menu item ${item.menuItemId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return option
|
||||||
|
})
|
||||||
|
const priceCents =
|
||||||
|
menuItemById.get(item.menuItemId)!.priceCents +
|
||||||
|
options.reduce((sum, option) => sum + option.priceDeltaCents, 0)
|
||||||
|
return { menuItemId: item.menuItemId, options, priceCents }
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCents = resolvedItems.reduce(
|
||||||
|
(sum, item) => sum + item.priceCents,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,22 +357,31 @@ export const placeOrder = async (
|
|||||||
|
|
||||||
const tagsToAssign = new Set<number>()
|
const tagsToAssign = new Set<number>()
|
||||||
|
|
||||||
for (const item of input.items) {
|
for (const item of resolvedItems) {
|
||||||
const menuItem = menuItemById.get(item.menuItemId)!
|
|
||||||
const [orderItem] = await tx
|
const [orderItem] = await tx
|
||||||
.insert(schema.orderItems)
|
.insert(schema.orderItems)
|
||||||
.values({
|
.values({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
menuItemId: menuItem.id,
|
menuItemId: item.menuItemId,
|
||||||
priceCents: menuItem.priceCents,
|
priceCents: item.priceCents,
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
if (item.options.length > 0) {
|
||||||
|
await tx.insert(schema.orderItemOptions).values(
|
||||||
|
item.options.map((option) => ({
|
||||||
|
orderItemId: orderItem.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const step = await initializeOrderItemRecipe(
|
const step = await initializeOrderItemRecipe(
|
||||||
tx,
|
tx,
|
||||||
orderItem.id,
|
orderItem.id,
|
||||||
menuItem.id,
|
item.menuItemId,
|
||||||
)
|
)
|
||||||
tagsToAssign.add(step.machineTagId)
|
tagsToAssign.add(step.machineTagId)
|
||||||
}
|
}
|
||||||
@@ -592,6 +645,7 @@ export type OrderItemStatusView = {
|
|||||||
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
||||||
queuePosition: number | null
|
queuePosition: number | null
|
||||||
}>
|
}>
|
||||||
|
options: Array<{ name: string; priceDeltaCents: number }>
|
||||||
etaSeconds: number | null
|
etaSeconds: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +677,14 @@ export const getOrderStatus = async (
|
|||||||
).map((row) => [row.id, row]),
|
).map((row) => [row.id, row]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const optionRows =
|
||||||
|
orderItemIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orderItemOptions)
|
||||||
|
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
|
||||||
|
|
||||||
const recipeRows =
|
const recipeRows =
|
||||||
orderItemIds.length === 0
|
orderItemIds.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -802,6 +864,9 @@ export const getOrderStatus = async (
|
|||||||
item.status !== 'picked_up' &&
|
item.status !== 'picked_up' &&
|
||||||
currentInstance?.status === 'queued',
|
currentInstance?.status === 'queued',
|
||||||
steps,
|
steps,
|
||||||
|
options: optionRows
|
||||||
|
.filter((option) => option.orderItemId === item.id)
|
||||||
|
.map(({ name, priceDeltaCents }) => ({ name, priceDeltaCents })),
|
||||||
etaSeconds,
|
etaSeconds,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -809,6 +874,7 @@ export const getOrderStatus = async (
|
|||||||
return {
|
return {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
|
createdAt: order.createdAt,
|
||||||
items: itemViews,
|
items: itemViews,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-23
@@ -9,71 +9,126 @@ import type { ReactNode } from 'react'
|
|||||||
|
|
||||||
const STORAGE_KEY = 'ghost-kitchen-basket'
|
const STORAGE_KEY = 'ghost-kitchen-basket'
|
||||||
|
|
||||||
|
export type BasketLine = {
|
||||||
|
menuItemId: number
|
||||||
|
optionIds: Array<number>
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredLines = Partial<Record<string, BasketLine>>
|
||||||
|
|
||||||
|
// Two lines with the same Menu Item and the same set of selected Options
|
||||||
|
// are the same line (their quantities combine) — order of selection
|
||||||
|
// shouldn't matter, so the key sorts the option ids first.
|
||||||
|
const lineKey = (menuItemId: number, optionIds: Array<number>) =>
|
||||||
|
`${menuItemId}:${[...optionIds].sort((a, b) => a - b).join(',')}`
|
||||||
|
|
||||||
|
const isBasketLine = (value: unknown): value is BasketLine =>
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as BasketLine).menuItemId === 'number' &&
|
||||||
|
Array.isArray((value as BasketLine).optionIds) &&
|
||||||
|
typeof (value as BasketLine).quantity === 'number'
|
||||||
|
|
||||||
type BasketContextValue = {
|
type BasketContextValue = {
|
||||||
quantities: Record<number, number>
|
lines: Array<BasketLine>
|
||||||
itemCount: number
|
itemCount: number
|
||||||
add: (menuItemId: number) => void
|
add: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
remove: (menuItemId: number) => void
|
remove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||||
clear: () => void
|
clear: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const BasketContext = createContext<BasketContextValue | null>(null)
|
const BasketContext = createContext<BasketContextValue | null>(null)
|
||||||
|
|
||||||
const readStoredQuantities = (): Record<number, number> => {
|
const readStoredLines = (): StoredLines => {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
return raw ? (JSON.parse(raw) as Record<number, number>) : {}
|
if (!raw) return {}
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return {}
|
||||||
|
|
||||||
|
const result: StoredLines = {}
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
// Earlier versions stored a flat `Record<menuItemId, quantity>` with
|
||||||
|
// no customization — read a leftover value like that as a plain,
|
||||||
|
// option-free line rather than dropping it.
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const menuItemId = Number(key)
|
||||||
|
if (!Number.isFinite(menuItemId) || value <= 0) continue
|
||||||
|
result[lineKey(menuItemId, [])] = {
|
||||||
|
menuItemId,
|
||||||
|
optionIds: [],
|
||||||
|
quantity: value,
|
||||||
|
}
|
||||||
|
} else if (isBasketLine(value)) {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BasketProvider = ({ children }: { children: ReactNode }) => {
|
export const BasketProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [quantities, setQuantities] = useState<Record<number, number>>({})
|
const [storedLines, setStoredLines] = useState<StoredLines>({})
|
||||||
|
|
||||||
// Read from localStorage after mount, not during initial render, so the
|
// Read from localStorage after mount, not during initial render, so the
|
||||||
// server-rendered and first client-rendered HTML match (no localStorage on
|
// server-rendered and first client-rendered HTML match (no localStorage on
|
||||||
// the server) and React doesn't flag a hydration mismatch.
|
// the server) and React doesn't flag a hydration mismatch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuantities(readStoredQuantities())
|
setStoredLines(readStoredLines())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quantities))
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(storedLines))
|
||||||
} catch {
|
} catch {
|
||||||
// Unavailable (private mode, quota) — basket still works for this
|
// Unavailable (private mode, quota) — basket still works for this
|
||||||
// page load, it just won't persist across a reload.
|
// page load, it just won't persist across a reload.
|
||||||
}
|
}
|
||||||
}, [quantities])
|
}, [storedLines])
|
||||||
|
|
||||||
const add = (menuItemId: number) =>
|
const add = (menuItemId: number, optionIds: Array<number> = []) => {
|
||||||
setQuantities((prev) => ({
|
const key = lineKey(menuItemId, optionIds)
|
||||||
|
setStoredLines((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
|
[key]: {
|
||||||
|
menuItemId,
|
||||||
|
optionIds,
|
||||||
|
quantity: (prev[key]?.quantity ?? 0) + 1,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const remove = (menuItemId: number) =>
|
const remove = (menuItemId: number, optionIds: Array<number> = []) => {
|
||||||
setQuantities((prev) => {
|
const key = lineKey(menuItemId, optionIds)
|
||||||
const current = prev[menuItemId] ?? 0
|
setStoredLines((prev) => {
|
||||||
if (current <= 1) {
|
const current = prev[key]
|
||||||
|
if (!current) return prev
|
||||||
|
if (current.quantity <= 1) {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[menuItemId]
|
delete next[key]
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
return { ...prev, [menuItemId]: current - 1 }
|
return { ...prev, [key]: { ...current, quantity: current.quantity - 1 } }
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const clear = () => setQuantities({})
|
const clear = () => setStoredLines({})
|
||||||
|
|
||||||
|
const lines = useMemo(
|
||||||
|
() => Object.values(storedLines).filter((line) => line !== undefined),
|
||||||
|
[storedLines],
|
||||||
|
)
|
||||||
const itemCount = useMemo(
|
const itemCount = useMemo(
|
||||||
() => Object.values(quantities).reduce((sum, quantity) => sum + quantity, 0),
|
() => lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||||
[quantities],
|
[lines],
|
||||||
)
|
)
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ quantities, itemCount, add, remove, clear }),
|
() => ({ lines, itemCount, add, remove, clear }),
|
||||||
[quantities, itemCount],
|
[lines, itemCount],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,33 @@
|
|||||||
|
const priceFormatter = new Intl.NumberFormat(
|
||||||
|
import.meta.env.VITE_LOCALE || 'de-DE',
|
||||||
|
{
|
||||||
|
style: 'currency',
|
||||||
|
currency: import.meta.env.VITE_CURRENCY || 'EUR',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Intl puts the currency symbol wherever the locale's convention says to
|
||||||
|
// (before the amount for en-US, say) — reassemble the parts ourselves so
|
||||||
|
// the currency always reads after the amount, regardless of locale.
|
||||||
|
const formatAmount = (value: number) => {
|
||||||
|
const parts = priceFormatter.formatToParts(value)
|
||||||
|
const currency = parts.find((part) => part.type === 'currency')?.value ?? ''
|
||||||
|
const amount = parts
|
||||||
|
.filter((part) => part.type !== 'currency')
|
||||||
|
.map((part) => part.value)
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
return `${amount} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatPrice = (cents: number) => formatAmount(cents / 100)
|
||||||
|
|
||||||
|
export const formatPriceDelta = (cents: number) => {
|
||||||
|
if (cents === 0) return null
|
||||||
|
const amount = formatAmount(Math.abs(cents) / 100)
|
||||||
|
return cents > 0 ? `+${amount}` : `-${amount}`
|
||||||
|
}
|
||||||
|
|
||||||
export const formatDuration = (totalSeconds: number) => {
|
export const formatDuration = (totalSeconds: number) => {
|
||||||
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60))
|
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60))
|
||||||
const hours = Math.floor(totalMinutes / 60)
|
const hours = Math.floor(totalMinutes / 60)
|
||||||
|
|||||||
+6
-1
@@ -6,6 +6,11 @@ import type Stripe from 'stripe'
|
|||||||
// exist here — build one explicitly to load Stripe's CJS build lazily.
|
// exist here — build one explicitly to load Stripe's CJS build lazily.
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
// Stripe wants the ISO 4217 code lowercased. Shares VITE_CURRENCY with the
|
||||||
|
// display-side formatter in lib/format.ts so the charged and displayed
|
||||||
|
// currency can't drift apart.
|
||||||
|
const currency = (import.meta.env.VITE_CURRENCY || 'EUR').toLowerCase()
|
||||||
|
|
||||||
export type Charge = {
|
export type Charge = {
|
||||||
chargeId: string
|
chargeId: string
|
||||||
amountCents: number
|
amountCents: number
|
||||||
@@ -64,7 +69,7 @@ export const createStripePaymentProvider = (
|
|||||||
charge: async (amountCents) => {
|
charge: async (amountCents) => {
|
||||||
const intent = await stripe.paymentIntents.create({
|
const intent = await stripe.paymentIntents.create({
|
||||||
amount: amountCents,
|
amount: amountCents,
|
||||||
currency: 'usd',
|
currency,
|
||||||
confirm: true,
|
confirm: true,
|
||||||
payment_method: 'pm_card_visa',
|
payment_method: 'pm_card_visa',
|
||||||
automatic_payment_methods: {
|
automatic_payment_methods: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||||||
import { Route as BasketRouteImport } from './routes/basket'
|
import { Route as BasketRouteImport } from './routes/basket'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as SignupRouteImport } from './routes/signup'
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
|
import { Route as ItemsMenuItemIdRouteImport } from './routes/items/$menuItemId'
|
||||||
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
|
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
|
||||||
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
@@ -37,6 +38,11 @@ const SignupRoute = SignupRouteImport.update({
|
|||||||
path: '/signup',
|
path: '/signup',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
|
||||||
|
id: '/items/$menuItemId',
|
||||||
|
path: '/items/$menuItemId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const OrdersIndexRoute = OrdersIndexRouteImport.update({
|
const OrdersIndexRoute = OrdersIndexRouteImport.update({
|
||||||
id: '/orders/',
|
id: '/orders/',
|
||||||
path: '/orders/',
|
path: '/orders/',
|
||||||
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
'/orders/': typeof OrdersIndexRoute
|
'/orders/': typeof OrdersIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
@@ -67,6 +74,7 @@ export interface FileRoutesByTo {
|
|||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
'/orders': typeof OrdersIndexRoute
|
'/orders': typeof OrdersIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
@@ -77,6 +85,7 @@ export interface FileRoutesById {
|
|||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
'/orders/': typeof OrdersIndexRoute
|
'/orders/': typeof OrdersIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
@@ -88,6 +97,7 @@ export interface FileRouteTypes {
|
|||||||
| '/basket'
|
| '/basket'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/items/$menuItemId'
|
||||||
| '/orders/$orderId'
|
| '/orders/$orderId'
|
||||||
| '/orders/'
|
| '/orders/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
@@ -97,6 +107,7 @@ export interface FileRouteTypes {
|
|||||||
| '/basket'
|
| '/basket'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/items/$menuItemId'
|
||||||
| '/orders/$orderId'
|
| '/orders/$orderId'
|
||||||
| '/orders'
|
| '/orders'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
@@ -106,6 +117,7 @@ export interface FileRouteTypes {
|
|||||||
| '/basket'
|
| '/basket'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/items/$menuItemId'
|
||||||
| '/orders/$orderId'
|
| '/orders/$orderId'
|
||||||
| '/orders/'
|
| '/orders/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
@@ -116,6 +128,7 @@ export interface RootRouteChildren {
|
|||||||
BasketRoute: typeof BasketRoute
|
BasketRoute: typeof BasketRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
SignupRoute: typeof SignupRoute
|
SignupRoute: typeof SignupRoute
|
||||||
|
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
|
||||||
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
||||||
OrdersIndexRoute: typeof OrdersIndexRoute
|
OrdersIndexRoute: typeof OrdersIndexRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
@@ -151,6 +164,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SignupRouteImport
|
preLoaderRoute: typeof SignupRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/items/$menuItemId': {
|
||||||
|
id: '/items/$menuItemId'
|
||||||
|
path: '/items/$menuItemId'
|
||||||
|
fullPath: '/items/$menuItemId'
|
||||||
|
preLoaderRoute: typeof ItemsMenuItemIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/orders/': {
|
'/orders/': {
|
||||||
id: '/orders/'
|
id: '/orders/'
|
||||||
path: '/orders'
|
path: '/orders'
|
||||||
@@ -180,6 +200,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
BasketRoute: BasketRoute,
|
BasketRoute: BasketRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
SignupRoute: SignupRoute,
|
SignupRoute: SignupRoute,
|
||||||
|
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
|
||||||
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
||||||
OrdersIndexRoute: OrdersIndexRoute,
|
OrdersIndexRoute: OrdersIndexRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
|
|||||||
@@ -10,11 +10,56 @@
|
|||||||
margin: 0 0 $space-4;
|
margin: 0 0 $space-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.stack {
|
||||||
color: $color-text-muted;
|
display: grid;
|
||||||
|
|
||||||
a {
|
> * {
|
||||||
color: $color-primary;
|
grid-area: 1 / 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
margin-bottom: $space-2;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
margin: 0 0 $space-3;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCta {
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-primary-contrast;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +93,14 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lineOptions {
|
||||||
|
list-style: disc;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.linePrice {
|
.linePrice {
|
||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -68,6 +121,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
|
|||||||
+91
-22
@@ -1,77 +1,144 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||||
|
|
||||||
|
import { BasketIcon } from '#/components/basket/BasketLink.tsx'
|
||||||
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
||||||
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||||
|
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
import { useBasket } from '#/lib/basket.tsx'
|
import { useBasket } from '#/lib/basket.tsx'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
import { getBrand } from '#/server/brand.ts'
|
import { getBrand } from '#/server/brand.ts'
|
||||||
import { getMenu } from '#/server/menu.ts'
|
import { getMenu } from '#/server/menu.ts'
|
||||||
|
|
||||||
import styles from './basket.module.scss'
|
import styles from './basket.module.scss'
|
||||||
|
|
||||||
|
const optionNames = (item: MenuItemWithRecipe, optionIds: Array<number>) =>
|
||||||
|
optionIds
|
||||||
|
.map((id) => item.options.find((option) => option.id === id)?.name)
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
|
||||||
const BasketPage = () => {
|
const BasketPage = () => {
|
||||||
const { menu } = Route.useLoaderData()
|
const { menu } = Route.useLoaderData()
|
||||||
const { quantities, add, remove, clear } = useBasket()
|
const { lines, add, remove, clear } = useBasket()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
|
||||||
const lines = menu
|
const resolvedLines = lines
|
||||||
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
|
.map((line) => ({
|
||||||
.filter(({ quantity }) => quantity > 0)
|
line,
|
||||||
|
item: menu.find((item) => item.id === line.menuItemId),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(resolved): resolved is { line: (typeof lines)[number]; item: MenuItemWithRecipe } =>
|
||||||
|
resolved.item !== undefined,
|
||||||
|
)
|
||||||
|
.map(({ line, item }) => {
|
||||||
|
const optionsTotalCents = line.optionIds.reduce((sum, optionId) => {
|
||||||
|
const option = item.options.find((o) => o.id === optionId)
|
||||||
|
return sum + (option?.priceDeltaCents ?? 0)
|
||||||
|
}, 0)
|
||||||
|
const unitPriceCents = item.priceCents + optionsTotalCents
|
||||||
|
return { line, item, unitPriceCents }
|
||||||
|
})
|
||||||
|
|
||||||
const totalCents = lines.reduce(
|
const totalCents = resolvedLines.reduce(
|
||||||
(sum, { item, quantity }) => sum + item.priceCents * quantity,
|
(sum, { line, unitPriceCents }) => sum + unitPriceCents * line.quantity,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
const items = lines.flatMap(({ item, quantity }) =>
|
const items = resolvedLines.flatMap(({ line }) =>
|
||||||
Array.from({ length: quantity }, () => ({ menuItemId: item.id })),
|
Array.from({ length: line.quantity }, () => ({
|
||||||
|
menuItemId: line.menuItemId,
|
||||||
|
selectedOptionIds: line.optionIds,
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isEmpty = resolvedLines.length === 0
|
||||||
|
const transition = { duration: reduceMotion ? 0 : 0.2, ease: 'easeOut' as const }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
<h1 className={styles.heading}>Your basket</h1>
|
<h1 className={styles.heading}>Your basket</h1>
|
||||||
|
|
||||||
{lines.length === 0 ? (
|
<div className={styles.stack}>
|
||||||
<p className={styles.empty}>
|
<AnimatePresence mode="wait">
|
||||||
Your basket is empty — <Link to="/">browse the menu</Link> to add
|
{isEmpty ? (
|
||||||
something.
|
<motion.div
|
||||||
|
key="empty"
|
||||||
|
className={styles.empty}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
|
<BasketIcon />
|
||||||
|
<p className={styles.emptyTitle}>Your basket is empty</p>
|
||||||
|
<p className={styles.emptyText}>
|
||||||
|
Add something tasty from the menu to get started.
|
||||||
</p>
|
</p>
|
||||||
|
<Link to="/" className={styles.emptyCta}>
|
||||||
|
Browse the menu
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<motion.div
|
||||||
|
key="items"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
<ul className={styles.lines}>
|
<ul className={styles.lines}>
|
||||||
{lines.map(({ item, quantity }) => (
|
{resolvedLines.map(({ line, item, unitPriceCents }) => {
|
||||||
<li key={item.id} className={styles.line}>
|
const names = optionNames(item, line.optionIds)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${line.menuItemId}:${line.optionIds.join(',')}`}
|
||||||
|
className={styles.line}
|
||||||
|
>
|
||||||
<div className={styles.lineInfo}>
|
<div className={styles.lineInfo}>
|
||||||
<span className={styles.lineName}>{item.name}</span>
|
<span className={styles.lineName}>{item.name}</span>
|
||||||
|
{names.length > 0 && (
|
||||||
|
<ul className={styles.lineOptions}>
|
||||||
|
{names.map((name) => (
|
||||||
|
<li key={name}>{name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
<span className={styles.linePrice}>
|
<span className={styles.linePrice}>
|
||||||
${((item.priceCents * quantity) / 100).toFixed(2)}
|
{formatPrice(unitPriceCents * line.quantity)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.quantity}>
|
<div className={styles.quantity}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
className={styles.stepperButton}
|
||||||
onClick={() => remove(item.id)}
|
onClick={() => remove(line.menuItemId, line.optionIds)}
|
||||||
aria-label={`Remove one ${item.name}`}
|
aria-label={`Remove one ${item.name}`}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
<AnimatedNumber value={quantity} className={styles.count} />
|
<AnimatedNumber
|
||||||
|
value={line.quantity}
|
||||||
|
className={styles.count}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
className={styles.stepperButton}
|
||||||
onClick={() => add(item.id)}
|
onClick={() => add(line.menuItemId, line.optionIds)}
|
||||||
aria-label={`Add one ${item.name}`}
|
aria-label={`Add one ${item.name}`}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className={styles.total}>
|
<div className={styles.total}>
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>${(totalCents / 100).toFixed(2)}</span>
|
<span>{formatPrice(totalCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CheckoutSection
|
<CheckoutSection
|
||||||
@@ -88,8 +155,10 @@ const BasketPage = () => {
|
|||||||
clear()
|
clear()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: $space-5 $space-3;
|
padding: $space-5 $space-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
margin: 0 0 $space-4;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import styles from './index.module.scss'
|
|||||||
|
|
||||||
const OrderingPage = () => {
|
const OrderingPage = () => {
|
||||||
const { menu } = Route.useLoaderData()
|
const { menu } = Route.useLoaderData()
|
||||||
const { quantities, add, remove } = useBasket()
|
const { lines, add, remove } = useBasket()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
<MenuList
|
<p className={styles.intro}>
|
||||||
menu={menu}
|
Zero cooks, all machine. A robotic kitchen grills, assembles, and
|
||||||
quantities={quantities}
|
plates your order in real time — watch it happen live, from click to
|
||||||
onAdd={add}
|
pickup.
|
||||||
onRemove={remove}
|
</p>
|
||||||
/>
|
<MenuList menu={menu} lines={lines} onAdd={add} onRemove={remove} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
margin: 0 0 $space-3;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-primary;
|
||||||
|
|
||||||
|
.backArrow {
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backArrow {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { MenuItemDetail } from '#/components/ordering/MenuItemDetail.tsx'
|
||||||
|
import { useBasket } from '#/lib/basket.tsx'
|
||||||
|
import { getBrand } from '#/server/brand.ts'
|
||||||
|
import { getMenu } from '#/server/menu.ts'
|
||||||
|
|
||||||
|
import styles from './$menuItemId.module.scss'
|
||||||
|
|
||||||
|
const BackArrowIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.backArrow}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="M11 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MenuItemRoute = () => {
|
||||||
|
const { item } = Route.useLoaderData()
|
||||||
|
const { lines, add, remove } = useBasket()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.page}>
|
||||||
|
<Link to="/" className={styles.backLink}>
|
||||||
|
<BackArrowIcon />
|
||||||
|
Back to menu
|
||||||
|
</Link>
|
||||||
|
<MenuItemDetail
|
||||||
|
item={item}
|
||||||
|
lines={lines.filter((line) => line.menuItemId === item.id)}
|
||||||
|
onAdd={add}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/items/$menuItemId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const brand = await getBrand()
|
||||||
|
const menu = await getMenu({ data: { brandId: brand.id } })
|
||||||
|
const item = menu.find((menuItem) => menuItem.id === Number(params.menuItemId))
|
||||||
|
if (!item) throw new Error('Menu item not found')
|
||||||
|
return { item }
|
||||||
|
},
|
||||||
|
component: MenuItemRoute,
|
||||||
|
})
|
||||||
@@ -30,6 +30,11 @@
|
|||||||
color: $color-primary-contrast;
|
color: $color-primary-contrast;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -30,6 +30,11 @@
|
|||||||
color: $color-primary-contrast;
|
color: $color-primary-contrast;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ const customerSchema = z.discriminatedUnion('type', [
|
|||||||
export const placeOrder = createServerFn({ method: 'POST' })
|
export const placeOrder = createServerFn({ method: 'POST' })
|
||||||
.validator(
|
.validator(
|
||||||
z.object({
|
z.object({
|
||||||
items: z.array(z.object({ menuItemId: z.number() })).min(1),
|
items: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
menuItemId: z.number(),
|
||||||
|
selectedOptionIds: z.array(z.number()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
customer: customerSchema,
|
customer: customerSchema,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
+111
-2
@@ -41,18 +41,19 @@ beforeEach(() => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await testDb.db.delete(schema.preparationStepInstances)
|
await testDb.db.delete(schema.preparationStepInstances)
|
||||||
await testDb.db.delete(schema.orderItemSteps)
|
await testDb.db.delete(schema.orderItemSteps)
|
||||||
|
await testDb.db.delete(schema.orderItemOptions)
|
||||||
await testDb.db.delete(schema.orderItems)
|
await testDb.db.delete(schema.orderItems)
|
||||||
await testDb.db.delete(schema.orders)
|
await testDb.db.delete(schema.orders)
|
||||||
})
|
})
|
||||||
|
|
||||||
const placeGuestOrder = (items: Array<{ menuItemId: number }>) =>
|
const placeGuestOrder = (items: ordering.PlaceOrderInput['items']) =>
|
||||||
ordering.placeOrder(testDb.db, payment, {
|
ordering.placeOrder(testDb.db, payment, {
|
||||||
items,
|
items,
|
||||||
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const placeAccountOrder = (
|
const placeAccountOrder = (
|
||||||
items: Array<{ menuItemId: number }>,
|
items: ordering.PlaceOrderInput['items'],
|
||||||
customerId: string,
|
customerId: string,
|
||||||
) =>
|
) =>
|
||||||
ordering.placeOrder(testDb.db, payment, {
|
ordering.placeOrder(testDb.db, payment, {
|
||||||
@@ -130,6 +131,28 @@ describe('menu', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('lists each menu item\'s customization Options', async () => {
|
||||||
|
const menu = await ordering.getMenu(testDb.db, seed.brand.id)
|
||||||
|
|
||||||
|
const pizza = menu.find((item) => item.name === 'Pizza')
|
||||||
|
expect(pizza?.options).toEqual(
|
||||||
|
seed.menuItemOptions.pizza.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const burger = menu.find((item) => item.name === 'Burger')
|
||||||
|
expect(burger?.options).toEqual(
|
||||||
|
seed.menuItemOptions.burger.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('placing an order', () => {
|
describe('placing an order', () => {
|
||||||
@@ -244,6 +267,92 @@ describe('placing an order', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('customizing an item', () => {
|
||||||
|
it("charges the adjusted price for selected Options and snapshots them on the Order Item", async () => {
|
||||||
|
const extraCheese = seed.menuItemOptions.pizza.find(
|
||||||
|
(option) => option.name === 'Extra cheese',
|
||||||
|
)!
|
||||||
|
const noOnions = seed.menuItemOptions.pizza.find(
|
||||||
|
(option) => option.name === 'No onions',
|
||||||
|
)!
|
||||||
|
|
||||||
|
let chargedAmountCents: number | undefined
|
||||||
|
const spyingPayment: typeof payment = {
|
||||||
|
...payment,
|
||||||
|
charge: async (amountCents) => {
|
||||||
|
chargedAmountCents = amountCents
|
||||||
|
return payment.charge(amountCents)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await ordering.placeOrder(testDb.db, spyingPayment, {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
menuItemId: seed.menuItems.pizza.id,
|
||||||
|
selectedOptionIds: [extraCheese.id, noOnions.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(chargedAmountCents).toBe(
|
||||||
|
seed.menuItems.pizza.priceCents + extraCheese.priceDeltaCents,
|
||||||
|
)
|
||||||
|
|
||||||
|
const status = await getStatus(order)
|
||||||
|
expect(status.items[0].options).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ name: 'Extra cheese', priceDeltaCents: 150 },
|
||||||
|
{ name: 'No onions', priceDeltaCents: 0 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(status.items[0].options).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places an unmodified Order Item with no Options selected', async () => {
|
||||||
|
const order = await placeGuestOrder([
|
||||||
|
{ menuItemId: seed.menuItems.burger.id },
|
||||||
|
])
|
||||||
|
|
||||||
|
const status = await getStatus(order)
|
||||||
|
expect(status.items[0].options).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects an Option that doesn't belong to the selected menu item", async () => {
|
||||||
|
const burgerOption = seed.menuItemOptions.burger[0]
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
placeGuestOrder([
|
||||||
|
{
|
||||||
|
menuItemId: seed.menuItems.pizza.id,
|
||||||
|
selectedOptionIds: [burgerOption.id],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).rejects.toThrow(/not valid for menu item/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refunds the full customized price on cancellation', async () => {
|
||||||
|
const extraPatty = seed.menuItemOptions.burger.find(
|
||||||
|
(option) => option.name === 'Extra patty',
|
||||||
|
)!
|
||||||
|
|
||||||
|
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
|
||||||
|
const order = await placeGuestOrder([
|
||||||
|
{
|
||||||
|
menuItemId: seed.menuItems.burger.id,
|
||||||
|
selectedOptionIds: [extraPatty.id],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const itemId = (await getStatus(order)).items[0].id
|
||||||
|
|
||||||
|
await ordering.cancelOrderItem(testDb.db, payment, itemId, accessFor(order))
|
||||||
|
|
||||||
|
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
|
||||||
|
seed.menuItems.burger.priceCents + extraPatty.priceDeltaCents,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('cancelling an Order Item', () => {
|
describe('cancelling an Order Item', () => {
|
||||||
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
|
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
|
||||||
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
|
||||||
|
|||||||
Reference in New Issue
Block a user