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_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
|
||||
# sign-in and sign-up pages. Leave unset to fall back to email/password only.
|
||||
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,
|
||||
"tag": "0003_soft_psynapse",
|
||||
"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;
|
||||
|
||||
&:hover {
|
||||
background: $color-surface;
|
||||
background: $color-border;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import styles from './BasketLink.module.scss'
|
||||
|
||||
const MotionLink = motion.create(Link)
|
||||
|
||||
const BasketIcon = () => (
|
||||
export const BasketIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
color: $color-primary-contrast;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -19,7 +19,7 @@ type Customer =
|
||||
type PlacedOrder = { id: number; guestAccessToken: string | null }
|
||||
|
||||
type CheckoutSectionProps = {
|
||||
items: Array<{ menuItemId: number }>
|
||||
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
|
||||
disabled: boolean
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-3;
|
||||
padding: $space-3;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius;
|
||||
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 {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -27,6 +53,7 @@
|
||||
.name {
|
||||
margin: 0 0 $space-1;
|
||||
font-size: 1.1rem;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -56,6 +83,11 @@
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
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 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'
|
||||
|
||||
type MenuListProps = {
|
||||
menu: Array<MenuItemWithRecipe>
|
||||
quantities: Record<number, number>
|
||||
onAdd: (menuItemId: number) => void
|
||||
onRemove: (menuItemId: number) => void
|
||||
lines: Array<BasketLine>
|
||||
onAdd: (menuItemId: number, optionIds?: Array<number>) => void
|
||||
onRemove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||
}
|
||||
|
||||
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
export const formatDuration = (seconds: number) => {
|
||||
const minutes = Math.round(seconds / 60)
|
||||
return minutes <= 1 ? '~1 min' : `~${minutes} min`
|
||||
return minutes <= 1 ? '1 min' : `${minutes} min`
|
||||
}
|
||||
|
||||
export const MenuList = ({
|
||||
menu,
|
||||
quantities,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: MenuListProps) => (
|
||||
export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
|
||||
<ul className={styles.list}>
|
||||
{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(
|
||||
(sum, step) => sum + step.estimatedDurationSeconds,
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<li key={item.id} className={styles.item}>
|
||||
const info = (
|
||||
<>
|
||||
<MenuItemThumbnail
|
||||
name={item.name}
|
||||
style={{ viewTransitionName: `menu-item-${item.id}` }}
|
||||
/>
|
||||
<div className={styles.info}>
|
||||
<h3 className={styles.name}>{item.name}</h3>
|
||||
{item.description && (
|
||||
@@ -42,25 +47,46 @@ export const MenuList = ({
|
||||
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.quantity}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
onClick={() => onRemove(item.id)}
|
||||
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)}
|
||||
aria-label={`Add one ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
onClick={() => onRemove(item.id)}
|
||||
disabled={(defaultLine?.quantity ?? 0) === 0}
|
||||
aria-label={`Remove one ${item.name}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<AnimatedNumber
|
||||
value={defaultLine?.quantity ?? 0}
|
||||
className={styles.count}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
onClick={() => onAdd(item.id)}
|
||||
aria-label={`Add one ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
@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 {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
@@ -36,11 +44,17 @@
|
||||
}
|
||||
|
||||
.orderStatus {
|
||||
margin: 0 0 $space-4;
|
||||
margin: 0 0 $space-1;
|
||||
color: $color-text-muted;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.orderDate {
|
||||
margin: 0 0 $space-4;
|
||||
color: $color-text-muted;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $color-danger;
|
||||
}
|
||||
@@ -73,6 +87,12 @@
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.itemOptions {
|
||||
margin: 0 0 $space-2;
|
||||
font-size: 0.85rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin: 0 0 $space-2;
|
||||
padding: 0;
|
||||
@@ -194,6 +214,7 @@
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
align-items: center;
|
||||
|
||||
@@ -206,6 +227,11 @@
|
||||
color: $color-text;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
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 {
|
||||
padding: $space-1 $space-2;
|
||||
border: none;
|
||||
|
||||
@@ -16,6 +16,16 @@ const helpMailto = (orderId: number, item: OrderItemStatusView) => {
|
||||
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 = () => (
|
||||
<svg
|
||||
className={styles.backArrow}
|
||||
@@ -32,9 +42,27 @@ const BackArrowIcon = () => (
|
||||
</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 = {
|
||||
id: number
|
||||
status: OrderStatus
|
||||
createdAt: Date
|
||||
items: Array<OrderItemStatusView>
|
||||
}
|
||||
|
||||
@@ -73,87 +101,128 @@ export const OrderStatusView = ({
|
||||
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
||||
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||
<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>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<ul className={styles.items}>
|
||||
{status.items.map((item) => (
|
||||
<li key={item.id} className={styles.item}>
|
||||
<div className={styles.itemHeader}>
|
||||
<span className={styles.itemName}>{item.menuItemName}</span>
|
||||
<span className={styles.itemStatus}>{item.status}</span>
|
||||
</div>
|
||||
<ol className={styles.timeline}>
|
||||
{item.steps.map((step) => (
|
||||
<li
|
||||
key={step.sequence}
|
||||
className={styles.step}
|
||||
data-status={step.status}
|
||||
aria-current={step.status === 'in_progress' ? 'step' : undefined}
|
||||
>
|
||||
<span className={styles.stepMarker} aria-hidden="true">
|
||||
<span className={styles.stepDot} />
|
||||
</span>
|
||||
<span className={styles.stepContent}>
|
||||
<span className={styles.stepLabel}>{step.name}</span>
|
||||
{step.status === 'queued' && step.queuePosition !== null && (
|
||||
<span className={styles.stepMeta}>
|
||||
#{step.queuePosition} in line
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{item.etaSeconds !== null && (
|
||||
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
{status.items.map((item) => {
|
||||
const cancelHint = cancelBlockedReason(item)
|
||||
return (
|
||||
<li key={item.id} className={styles.item}>
|
||||
<div className={styles.itemHeader}>
|
||||
<span className={styles.itemName}>{item.menuItemName}</span>
|
||||
<span className={styles.itemStatus}>{item.status}</span>
|
||||
</div>
|
||||
{item.options.length > 0 && (
|
||||
<p className={styles.itemOptions}>
|
||||
{item.options.map((option) => option.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<ol className={styles.timeline}>
|
||||
{item.steps.map((step) => (
|
||||
<li
|
||||
key={step.sequence}
|
||||
className={styles.step}
|
||||
data-status={step.status}
|
||||
aria-current={step.status === 'in_progress' ? 'step' : undefined}
|
||||
>
|
||||
<span className={styles.stepMarker} aria-hidden="true">
|
||||
<span className={styles.stepDot} />
|
||||
</span>
|
||||
<span className={styles.stepContent}>
|
||||
<span className={styles.stepLabel}>{step.name}</span>
|
||||
{step.status === 'queued' && step.queuePosition !== null && (
|
||||
<span className={styles.stepMeta}>
|
||||
#{step.queuePosition} in line
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{item.etaSeconds !== null && (
|
||||
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingItemId === item.id}
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
Buy again
|
||||
</button>
|
||||
|
||||
|
||||
{item.status === 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingItemId === item.id}
|
||||
onClick={() =>
|
||||
void runAction(item.id, () =>
|
||||
markOrderItemPickedUp({
|
||||
data: { orderItemId: item.id, guestAccessToken },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
I picked this up
|
||||
Buy again
|
||||
</button>
|
||||
)}
|
||||
<a href={helpMailto(status.id, item)}>Get help</a>
|
||||
{item.cancellable && (
|
||||
|
||||
{item.status === 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingItemId === item.id}
|
||||
onClick={() =>
|
||||
void runAction(item.id, () =>
|
||||
markOrderItemPickedUp({
|
||||
data: { orderItemId: item.id, guestAccessToken },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
I picked this up
|
||||
</button>
|
||||
)}
|
||||
<a href={helpMailto(status.id, item)}>Get help</a>
|
||||
{cancelHint && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelButton}
|
||||
disabled={pendingItemId === item.id}
|
||||
onClick={() =>
|
||||
void runAction(item.id, () =>
|
||||
cancelOrderItem({
|
||||
data: { orderItemId: item.id, guestAccessToken },
|
||||
}),
|
||||
)
|
||||
}
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelButton}
|
||||
disabled={pendingItemId === item.id}
|
||||
onClick={() =>
|
||||
void runAction(item.id, () =>
|
||||
cancelOrderItem({
|
||||
data: { orderItemId: item.id, guestAccessToken },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
|
||||
import { formatPrice } from '#/lib/format.ts'
|
||||
|
||||
import styles from './OrdersListView.module.scss'
|
||||
|
||||
@@ -8,8 +9,6 @@ type OrdersListViewProps = {
|
||||
orders: Array<CustomerOrderSummary>
|
||||
}
|
||||
|
||||
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
||||
|
||||
const formatItems = (items: CustomerOrderSummary['items']) =>
|
||||
items
|
||||
.map(({ menuItemName, quantity }) =>
|
||||
|
||||
@@ -65,6 +65,17 @@ export const menuItemSteps = pgTable('menu_item_steps', {
|
||||
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 type OrderStatus = (typeof orderStatusValues)[number]
|
||||
|
||||
@@ -123,6 +134,18 @@ export const orderItemSteps = pgTable('order_item_steps', {
|
||||
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 = [
|
||||
'queued',
|
||||
'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
|
||||
.insert(schema.menuItems)
|
||||
.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 {
|
||||
brand,
|
||||
machineTags: {
|
||||
@@ -137,6 +155,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
||||
},
|
||||
machines: { prep1, oven1, grill1, assembly1 },
|
||||
menuItems: { pizza, burger },
|
||||
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+80
-14
@@ -57,6 +57,11 @@ export type MenuItemWithRecipe = {
|
||||
machineTag: string
|
||||
estimatedDurationSeconds: number
|
||||
}>
|
||||
options: Array<{
|
||||
id: number
|
||||
name: string
|
||||
priceDeltaCents: number
|
||||
}>
|
||||
}
|
||||
|
||||
export const getMenu = async (
|
||||
@@ -68,6 +73,8 @@ export const getMenu = async (
|
||||
.from(schema.menuItems)
|
||||
.where(eq(schema.menuItems.brandId, brandId))
|
||||
|
||||
const menuItemIds = items.map((item) => item.id)
|
||||
|
||||
const steps = await db
|
||||
.select({
|
||||
menuItemId: schema.menuItemSteps.menuItemId,
|
||||
@@ -81,14 +88,17 @@ export const getMenu = async (
|
||||
schema.machineTags,
|
||||
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
||||
)
|
||||
.where(
|
||||
inArray(
|
||||
schema.menuItemSteps.menuItemId,
|
||||
items.map((item) => item.id),
|
||||
),
|
||||
)
|
||||
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
|
||||
.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) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
@@ -102,6 +112,9 @@ export const getMenu = async (
|
||||
machineTag,
|
||||
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 = {
|
||||
items: Array<{ menuItemId: number }>
|
||||
items: Array<{ menuItemId: number; selectedOptionIds?: Array<number> }>
|
||||
customer:
|
||||
| { type: 'guest'; name: string; contact: string }
|
||||
| { type: 'account'; customerId: string }
|
||||
@@ -276,8 +289,39 @@ export const placeOrder = async (
|
||||
}
|
||||
}
|
||||
|
||||
const totalCents = input.items.reduce(
|
||||
(sum, item) => sum + menuItemById.get(item.menuItemId)!.priceCents,
|
||||
const allSelectedOptionIds = [
|
||||
...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,
|
||||
)
|
||||
|
||||
@@ -313,22 +357,31 @@ export const placeOrder = async (
|
||||
|
||||
const tagsToAssign = new Set<number>()
|
||||
|
||||
for (const item of input.items) {
|
||||
const menuItem = menuItemById.get(item.menuItemId)!
|
||||
for (const item of resolvedItems) {
|
||||
const [orderItem] = await tx
|
||||
.insert(schema.orderItems)
|
||||
.values({
|
||||
orderId: order.id,
|
||||
menuItemId: menuItem.id,
|
||||
priceCents: menuItem.priceCents,
|
||||
menuItemId: item.menuItemId,
|
||||
priceCents: item.priceCents,
|
||||
status: 'paid',
|
||||
})
|
||||
.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(
|
||||
tx,
|
||||
orderItem.id,
|
||||
menuItem.id,
|
||||
item.menuItemId,
|
||||
)
|
||||
tagsToAssign.add(step.machineTagId)
|
||||
}
|
||||
@@ -592,6 +645,7 @@ export type OrderItemStatusView = {
|
||||
status: 'done' | 'in_progress' | 'queued' | 'pending'
|
||||
queuePosition: number | null
|
||||
}>
|
||||
options: Array<{ name: string; priceDeltaCents: number }>
|
||||
etaSeconds: number | null
|
||||
}
|
||||
|
||||
@@ -623,6 +677,14 @@ export const getOrderStatus = async (
|
||||
).map((row) => [row.id, row]),
|
||||
)
|
||||
|
||||
const optionRows =
|
||||
orderItemIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(schema.orderItemOptions)
|
||||
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
|
||||
|
||||
const recipeRows =
|
||||
orderItemIds.length === 0
|
||||
? []
|
||||
@@ -802,6 +864,9 @@ export const getOrderStatus = async (
|
||||
item.status !== 'picked_up' &&
|
||||
currentInstance?.status === 'queued',
|
||||
steps,
|
||||
options: optionRows
|
||||
.filter((option) => option.orderItemId === item.id)
|
||||
.map(({ name, priceDeltaCents }) => ({ name, priceDeltaCents })),
|
||||
etaSeconds,
|
||||
}
|
||||
})
|
||||
@@ -809,6 +874,7 @@ export const getOrderStatus = async (
|
||||
return {
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
createdAt: order.createdAt,
|
||||
items: itemViews,
|
||||
}
|
||||
}
|
||||
|
||||
+78
-23
@@ -9,71 +9,126 @@ import type { ReactNode } from 'react'
|
||||
|
||||
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 = {
|
||||
quantities: Record<number, number>
|
||||
lines: Array<BasketLine>
|
||||
itemCount: number
|
||||
add: (menuItemId: number) => void
|
||||
remove: (menuItemId: number) => void
|
||||
add: (menuItemId: number, optionIds?: Array<number>) => void
|
||||
remove: (menuItemId: number, optionIds?: Array<number>) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const BasketContext = createContext<BasketContextValue | null>(null)
|
||||
|
||||
const readStoredQuantities = (): Record<number, number> => {
|
||||
const readStoredLines = (): StoredLines => {
|
||||
try {
|
||||
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 {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// server-rendered and first client-rendered HTML match (no localStorage on
|
||||
// the server) and React doesn't flag a hydration mismatch.
|
||||
useEffect(() => {
|
||||
setQuantities(readStoredQuantities())
|
||||
setStoredLines(readStoredLines())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quantities))
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(storedLines))
|
||||
} catch {
|
||||
// Unavailable (private mode, quota) — basket still works for this
|
||||
// page load, it just won't persist across a reload.
|
||||
}
|
||||
}, [quantities])
|
||||
}, [storedLines])
|
||||
|
||||
const add = (menuItemId: number) =>
|
||||
setQuantities((prev) => ({
|
||||
const add = (menuItemId: number, optionIds: Array<number> = []) => {
|
||||
const key = lineKey(menuItemId, optionIds)
|
||||
setStoredLines((prev) => ({
|
||||
...prev,
|
||||
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
|
||||
[key]: {
|
||||
menuItemId,
|
||||
optionIds,
|
||||
quantity: (prev[key]?.quantity ?? 0) + 1,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const remove = (menuItemId: number) =>
|
||||
setQuantities((prev) => {
|
||||
const current = prev[menuItemId] ?? 0
|
||||
if (current <= 1) {
|
||||
const remove = (menuItemId: number, optionIds: Array<number> = []) => {
|
||||
const key = lineKey(menuItemId, optionIds)
|
||||
setStoredLines((prev) => {
|
||||
const current = prev[key]
|
||||
if (!current) return prev
|
||||
if (current.quantity <= 1) {
|
||||
const next = { ...prev }
|
||||
delete next[menuItemId]
|
||||
delete next[key]
|
||||
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(
|
||||
() => Object.values(quantities).reduce((sum, quantity) => sum + quantity, 0),
|
||||
[quantities],
|
||||
() => lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
[lines],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ quantities, itemCount, add, remove, clear }),
|
||||
[quantities, itemCount],
|
||||
() => ({ lines, itemCount, add, remove, clear }),
|
||||
[lines, itemCount],
|
||||
)
|
||||
|
||||
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) => {
|
||||
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 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.
|
||||
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 = {
|
||||
chargeId: string
|
||||
amountCents: number
|
||||
@@ -64,7 +69,7 @@ export const createStripePaymentProvider = (
|
||||
charge: async (amountCents) => {
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount: amountCents,
|
||||
currency: 'usd',
|
||||
currency,
|
||||
confirm: true,
|
||||
payment_method: 'pm_card_visa',
|
||||
automatic_payment_methods: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as BasketRouteImport } from './routes/basket'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
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 OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
@@ -37,6 +38,11 @@ const SignupRoute = SignupRouteImport.update({
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
|
||||
id: '/items/$menuItemId',
|
||||
path: '/items/$menuItemId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OrdersIndexRoute = OrdersIndexRouteImport.update({
|
||||
id: '/orders/',
|
||||
path: '/orders/',
|
||||
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/orders/': typeof OrdersIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
@@ -67,6 +74,7 @@ export interface FileRoutesByTo {
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/orders': typeof OrdersIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
@@ -77,6 +85,7 @@ export interface FileRoutesById {
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/orders/': typeof OrdersIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
@@ -88,6 +97,7 @@ export interface FileRouteTypes {
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/items/$menuItemId'
|
||||
| '/orders/$orderId'
|
||||
| '/orders/'
|
||||
| '/api/auth/$'
|
||||
@@ -97,6 +107,7 @@ export interface FileRouteTypes {
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/items/$menuItemId'
|
||||
| '/orders/$orderId'
|
||||
| '/orders'
|
||||
| '/api/auth/$'
|
||||
@@ -106,6 +117,7 @@ export interface FileRouteTypes {
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/items/$menuItemId'
|
||||
| '/orders/$orderId'
|
||||
| '/orders/'
|
||||
| '/api/auth/$'
|
||||
@@ -116,6 +128,7 @@ export interface RootRouteChildren {
|
||||
BasketRoute: typeof BasketRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
|
||||
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
||||
OrdersIndexRoute: typeof OrdersIndexRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
@@ -151,6 +164,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/items/$menuItemId': {
|
||||
id: '/items/$menuItemId'
|
||||
path: '/items/$menuItemId'
|
||||
fullPath: '/items/$menuItemId'
|
||||
preLoaderRoute: typeof ItemsMenuItemIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/orders/': {
|
||||
id: '/orders/'
|
||||
path: '/orders'
|
||||
@@ -180,6 +200,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
BasketRoute: BasketRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
|
||||
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
||||
OrdersIndexRoute: OrdersIndexRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
|
||||
@@ -10,11 +10,56 @@
|
||||
margin: 0 0 $space-4;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: $color-text-muted;
|
||||
.stack {
|
||||
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;
|
||||
}
|
||||
|
||||
.lineOptions {
|
||||
list-style: disc;
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: $color-text-muted;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.linePrice {
|
||||
color: $color-text-muted;
|
||||
font-size: 0.9rem;
|
||||
@@ -68,6 +121,11 @@
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
|
||||
+135
-66
@@ -1,95 +1,164 @@
|
||||
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 { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||
import { useBasket } from '#/lib/basket.tsx'
|
||||
import { formatPrice } from '#/lib/format.ts'
|
||||
import { getBrand } from '#/server/brand.ts'
|
||||
import { getMenu } from '#/server/menu.ts'
|
||||
|
||||
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 { menu } = Route.useLoaderData()
|
||||
const { quantities, add, remove, clear } = useBasket()
|
||||
const { lines, add, remove, clear } = useBasket()
|
||||
const navigate = useNavigate()
|
||||
const reduceMotion = useReducedMotion()
|
||||
|
||||
const lines = menu
|
||||
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
|
||||
.filter(({ quantity }) => quantity > 0)
|
||||
const resolvedLines = lines
|
||||
.map((line) => ({
|
||||
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(
|
||||
(sum, { item, quantity }) => sum + item.priceCents * quantity,
|
||||
const totalCents = resolvedLines.reduce(
|
||||
(sum, { line, unitPriceCents }) => sum + unitPriceCents * line.quantity,
|
||||
0,
|
||||
)
|
||||
|
||||
const items = lines.flatMap(({ item, quantity }) =>
|
||||
Array.from({ length: quantity }, () => ({ menuItemId: item.id })),
|
||||
const items = resolvedLines.flatMap(({ line }) =>
|
||||
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 (
|
||||
<main className={styles.page}>
|
||||
<h1 className={styles.heading}>Your basket</h1>
|
||||
|
||||
{lines.length === 0 ? (
|
||||
<p className={styles.empty}>
|
||||
Your basket is empty — <Link to="/">browse the menu</Link> to add
|
||||
something.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className={styles.lines}>
|
||||
{lines.map(({ item, quantity }) => (
|
||||
<li key={item.id} className={styles.line}>
|
||||
<div className={styles.lineInfo}>
|
||||
<span className={styles.lineName}>{item.name}</span>
|
||||
<span className={styles.linePrice}>
|
||||
${((item.priceCents * quantity) / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.quantity}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
onClick={() => remove(item.id)}
|
||||
aria-label={`Remove one ${item.name}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<AnimatedNumber value={quantity} className={styles.count} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
onClick={() => add(item.id)}
|
||||
aria-label={`Add one ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.stack}>
|
||||
<AnimatePresence mode="wait">
|
||||
{isEmpty ? (
|
||||
<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>
|
||||
<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}>
|
||||
{resolvedLines.map(({ line, item, unitPriceCents }) => {
|
||||
const names = optionNames(item, line.optionIds)
|
||||
return (
|
||||
<li
|
||||
key={`${line.menuItemId}:${line.optionIds.join(',')}`}
|
||||
className={styles.line}
|
||||
>
|
||||
<div className={styles.lineInfo}>
|
||||
<span className={styles.lineName}>{item.name}</span>
|
||||
{names.length > 0 && (
|
||||
<ul className={styles.lineOptions}>
|
||||
{names.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<span className={styles.linePrice}>
|
||||
{formatPrice(unitPriceCents * line.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.quantity}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
onClick={() => remove(line.menuItemId, line.optionIds)}
|
||||
aria-label={`Remove one ${item.name}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<AnimatedNumber
|
||||
value={line.quantity}
|
||||
className={styles.count}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
onClick={() => add(line.menuItemId, line.optionIds)}
|
||||
aria-label={`Add one ${item.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className={styles.total}>
|
||||
<span>Total</span>
|
||||
<span>${(totalCents / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className={styles.total}>
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(totalCents)}</span>
|
||||
</div>
|
||||
|
||||
<CheckoutSection
|
||||
items={items}
|
||||
disabled={items.length === 0}
|
||||
onPlaced={async (order) => {
|
||||
await navigate({
|
||||
to: '/orders/$orderId',
|
||||
params: { orderId: String(order.id) },
|
||||
search: order.guestAccessToken
|
||||
? { token: order.guestAccessToken }
|
||||
: {},
|
||||
})
|
||||
clear()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CheckoutSection
|
||||
items={items}
|
||||
disabled={items.length === 0}
|
||||
onPlaced={async (order) => {
|
||||
await navigate({
|
||||
to: '/orders/$orderId',
|
||||
params: { orderId: String(order.id) },
|
||||
search: order.guestAccessToken
|
||||
? { token: order.guestAccessToken }
|
||||
: {},
|
||||
})
|
||||
clear()
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,3 +5,8 @@
|
||||
margin: 0 auto;
|
||||
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 { menu } = Route.useLoaderData()
|
||||
const { quantities, add, remove } = useBasket()
|
||||
const { lines, add, remove } = useBasket()
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<MenuList
|
||||
menu={menu}
|
||||
quantities={quantities}
|
||||
onAdd={add}
|
||||
onRemove={remove}
|
||||
/>
|
||||
<p className={styles.intro}>
|
||||
Zero cooks, all machine. A robotic kitchen grills, assembles, and
|
||||
plates your order in real time — watch it happen live, from click to
|
||||
pickup.
|
||||
</p>
|
||||
<MenuList menu={menu} lines={lines} onAdd={add} onRemove={remove} />
|
||||
</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;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
color: $color-primary-contrast;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -26,7 +26,14 @@ const customerSchema = z.discriminatedUnion('type', [
|
||||
export const placeOrder = createServerFn({ method: 'POST' })
|
||||
.validator(
|
||||
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,
|
||||
}),
|
||||
)
|
||||
|
||||
+111
-2
@@ -41,18 +41,19 @@ beforeEach(() => {
|
||||
afterEach(async () => {
|
||||
await testDb.db.delete(schema.preparationStepInstances)
|
||||
await testDb.db.delete(schema.orderItemSteps)
|
||||
await testDb.db.delete(schema.orderItemOptions)
|
||||
await testDb.db.delete(schema.orderItems)
|
||||
await testDb.db.delete(schema.orders)
|
||||
})
|
||||
|
||||
const placeGuestOrder = (items: Array<{ menuItemId: number }>) =>
|
||||
const placeGuestOrder = (items: ordering.PlaceOrderInput['items']) =>
|
||||
ordering.placeOrder(testDb.db, payment, {
|
||||
items,
|
||||
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
|
||||
})
|
||||
|
||||
const placeAccountOrder = (
|
||||
items: Array<{ menuItemId: number }>,
|
||||
items: ordering.PlaceOrderInput['items'],
|
||||
customerId: string,
|
||||
) =>
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user