diff --git a/CONTEXT.md b/CONTEXT.md index f482ba4..712cd7a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,7 +23,7 @@ The single payment transaction covering an Order's full total, created once at p _Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments) **Order Item**: -A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress). +A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress). Separately, the Kitchen itself can cancel an Order Item at any point before it's ready (e.g. a Machine fault), even mid-Step, also with a refund. _Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state) **Guest Access Token**: @@ -37,6 +37,9 @@ The ordered list of Preparation Steps required to prepare a Menu Item. A Menu It One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion. _Avoid_: Stage (use Step consistently) +**Delay**: +Extra time a Machine reports on top of a claimed Preparation Step's estimated duration. Delays add to the Order Item's time to completion and are shown to the customer as the item running late. + **Machine**: A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel. diff --git a/drizzle/0007_dazzling_norman_osborn.sql b/drizzle/0007_dazzling_norman_osborn.sql new file mode 100644 index 0000000..9ec21df --- /dev/null +++ b/drizzle/0007_dazzling_norman_osborn.sql @@ -0,0 +1,2 @@ +ALTER TABLE "order_items" ADD COLUMN "cancelled_by" text;--> statement-breakpoint +ALTER TABLE "preparation_step_instances" ADD COLUMN "delay_seconds" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..e0d6b97 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,883 @@ +{ + "id": "1cec3157-9481-4f6a-83b0-a79064d2bed5", + "prevId": "4086d6cd-5c93-4617-a15b-a90331b459a1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.allergens": { + "name": "allergens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "allergens_name_idx": { + "name": "allergens_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brands": { + "name": "brands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine_tags": { + "name": "machine_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_tags_name_idx": { + "name": "machine_tags_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machines": { + "name": "machines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_tag_id": { + "name": "machine_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "machines_machine_tag_id_machine_tags_id_fk": { + "name": "machines_machine_tag_id_machine_tags_id_fk", + "tableFrom": "machines", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menu_item_allergens": { + "name": "menu_item_allergens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "menu_item_id": { + "name": "menu_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "allergen_id": { + "name": "allergen_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "menu_item_allergens_item_allergen_idx": { + "name": "menu_item_allergens_item_allergen_idx", + "columns": [ + { + "expression": "menu_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "allergen_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "menu_item_allergens_menu_item_id_menu_items_id_fk": { + "name": "menu_item_allergens_menu_item_id_menu_items_id_fk", + "tableFrom": "menu_item_allergens", + "tableTo": "menu_items", + "columnsFrom": [ + "menu_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "menu_item_allergens_allergen_id_allergens_id_fk": { + "name": "menu_item_allergens_allergen_id_allergens_id_fk", + "tableFrom": "menu_item_allergens", + "tableTo": "allergens", + "columnsFrom": [ + "allergen_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menu_item_options": { + "name": "menu_item_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "menu_item_id": { + "name": "menu_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_delta_cents": { + "name": "price_delta_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "menu_item_options_menu_item_id_menu_items_id_fk": { + "name": "menu_item_options_menu_item_id_menu_items_id_fk", + "tableFrom": "menu_item_options", + "tableTo": "menu_items", + "columnsFrom": [ + "menu_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menu_item_steps": { + "name": "menu_item_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "menu_item_id": { + "name": "menu_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_tag_id": { + "name": "machine_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "estimated_duration_seconds": { + "name": "estimated_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "menu_item_steps_menu_item_id_menu_items_id_fk": { + "name": "menu_item_steps_menu_item_id_menu_items_id_fk", + "tableFrom": "menu_item_steps", + "tableTo": "menu_items", + "columnsFrom": [ + "menu_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "menu_item_steps_machine_tag_id_machine_tags_id_fk": { + "name": "menu_item_steps_machine_tag_id_machine_tags_id_fk", + "tableFrom": "menu_item_steps", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menu_items": { + "name": "menu_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "brand_id": { + "name": "brand_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "menu_items_brand_id_brands_id_fk": { + "name": "menu_items_brand_id_brands_id_fk", + "tableFrom": "menu_items", + "tableTo": "brands", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_item_options": { + "name": "order_item_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_delta_cents": { + "name": "price_delta_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "order_item_options_order_item_id_order_items_id_fk": { + "name": "order_item_options_order_item_id_order_items_id_fk", + "tableFrom": "order_item_options", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_item_steps": { + "name": "order_item_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_tag_id": { + "name": "machine_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "estimated_duration_seconds": { + "name": "estimated_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "order_item_steps_order_item_id_order_items_id_fk": { + "name": "order_item_steps_order_item_id_order_items_id_fk", + "tableFrom": "order_item_steps", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_item_steps_machine_tag_id_machine_tags_id_fk": { + "name": "order_item_steps_machine_tag_id_machine_tags_id_fk", + "tableFrom": "order_item_steps", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "menu_item_id": { + "name": "menu_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'placed'" + }, + "cancelled_by": { + "name": "cancelled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "delay_seconds": { + "name": "delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "machine_id": { + "name": "machine_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "done_at": { + "name": "done_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "preparation_step_instances_order_item_id_order_items_id_fk": { + "name": "preparation_step_instances_order_item_id_order_items_id_fk", + "tableFrom": "preparation_step_instances", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "preparation_step_instances_machine_tag_id_machine_tags_id_fk": { + "name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk", + "tableFrom": "preparation_step_instances", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "preparation_step_instances_machine_id_machines_id_fk": { + "name": "preparation_step_instances_machine_id_machines_id_fk", + "tableFrom": "preparation_step_instances", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 89b016e..2bde7e8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1789637974330, "tag": "0006_worried_shooting_star", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1790804884144, + "tag": "0007_dazzling_norman_osborn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/orders/ItemStatusBanner.module.scss b/src/components/orders/ItemStatusBanner.module.scss new file mode 100644 index 0000000..872bd80 --- /dev/null +++ b/src/components/orders/ItemStatusBanner.module.scss @@ -0,0 +1,42 @@ +@use '../../styles/variables' as *; + +.banner { + display: flex; + align-items: center; + gap: $space-2; + color: $color-success; + + &[data-variant='cancelled'] { + color: $color-danger; + } + + &[data-variant='delayed'] { + color: $color-warning; + } +} + +.icon { + width: 1.75rem; + height: 1.75rem; + flex-shrink: 0; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.text { + display: flex; + flex-direction: column; +} + +.label { + font-size: 0.9rem; + font-weight: 600; +} + +.detail { + font-size: 0.8rem; + color: $color-text-muted; +} diff --git a/src/components/orders/ItemStatusBanner.tsx b/src/components/orders/ItemStatusBanner.tsx new file mode 100644 index 0000000..0f42fe1 --- /dev/null +++ b/src/components/orders/ItemStatusBanner.tsx @@ -0,0 +1,69 @@ +import { motion, useReducedMotion } from 'framer-motion' + +import styles from './ItemStatusBanner.module.scss' + +export type ItemStatusBannerVariant = + 'ready' | 'pickedUp' | 'cancelled' | 'delayed' + +const iconPaths = { + ready: ['M7.5 12.5l3 3 6-6.5'], + pickedUp: ['M7.5 12.5l3 3 6-6.5'], + cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'], + delayed: ['M12 7v5', 'M12 12l3.5 2'], +} satisfies Record> + +type ItemStatusBannerProps = { + variant: ItemStatusBannerVariant + label: string + detail?: string + animateIn: boolean +} + +export const ItemStatusBanner = ({ + variant, + label, + detail, + animateIn, +}: ItemStatusBannerProps) => { + const reduceMotion = useReducedMotion() + const skipAnimation = !animateIn || reduceMotion + + return ( + + + + {label} + {detail && {detail}} + + + ) +} diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss index 382feae..2f3bbfc 100644 --- a/src/components/orders/OrderStatusView.module.scss +++ b/src/components/orders/OrderStatusView.module.scss @@ -64,7 +64,9 @@ color: $color-text-muted; font-size: 0.85rem; cursor: pointer; - transition: border-color 0.15s ease, color 0.15s ease; + transition: + border-color 0.15s ease, + color 0.15s ease; &:hover:not(:disabled) { border-color: $color-primary; @@ -104,17 +106,42 @@ margin-bottom: $space-2; } -.itemStatus { - text-transform: capitalize; - color: $color-text-muted; -} - .itemOptions { margin: 0 0 $space-2; font-size: 0.85rem; color: $color-text-muted; } +// The negative margin + matching padding leaves room for the in-progress +// dot's sonar ping, which the collapse animation's overflow would clip. +.timelineWrapper { + overflow: hidden; + margin: (-$space-2); + padding: $space-2; +} + +.bannerRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-2; + margin: 0 0 $space-2; +} + +.stepsToggle { + padding: 0; + border: none; + background: none; + color: $color-text-muted; + font-size: 0.85rem; + text-decoration: underline; + cursor: pointer; + + &:hover { + color: $color-primary; + } +} + .timeline { margin: 0 0 $space-2; padding: 0; @@ -186,6 +213,8 @@ } .step[data-status='done'] { + opacity: 0.5; + .stepDot { border-color: $color-success; background: $color-success; @@ -193,7 +222,6 @@ .stepLabel { color: $color-text; - text-decoration: line-through; } } diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index 806d2eb..53f9a7f 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -1,29 +1,82 @@ import { Link, useRouter } from '@tanstack/react-router' -import { motion, useReducedMotion } from 'framer-motion' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { useState } from 'react' import type { OrderStatus } from '#/db/schema.ts' import type { OrderItemStatusView } from '#/domain/ordering.ts' -import { formatEtaMessage } from '#/lib/format.ts' +import { formatDuration, formatEtaMessage } from '#/lib/format.ts' import { - advanceOrderItemStep, + advanceSimulatedKitchen, cancelOrderItem, getOrderStatus, markOrderItemPickedUp, } from '#/server/orders.ts' import styles from './OrderStatusView.module.scss' +import { ItemStatusBanner } from './ItemStatusBanner.tsx' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -// Compresses a step's real-world estimated duration into something worth -// watching in a dev session, while keeping slower steps visibly slower than -// quick ones rather than flattening every step to the same delay. -const simulatedStepDelayMs = (estimatedDurationSeconds: number) => - Math.min(Math.max(estimatedDurationSeconds * 50, 10_000), 30_000) +const isSettled = (item: OrderItemStatusView) => + item.status === 'ready' || + item.status === 'picked_up' || + item.status === 'cancelled' + +// `animationKey` groups variants that should read as one continuous banner: +// going from ready to picked up swaps the label without replaying the check. +const getItemBanner = (item: OrderItemStatusView) => { + if (item.status === 'ready') { + return { + animationKey: 'done', + variant: 'ready', + label: 'Ready for pickup', + } as const + } + if (item.status === 'picked_up') { + return { + animationKey: 'done', + variant: 'pickedUp', + label: 'Picked up', + } as const + } + if (item.status === 'cancelled') { + return item.cancelledBy === 'kitchen' + ? ({ + animationKey: 'cancelled', + variant: 'cancelled', + label: 'Cancelled by the kitchen', + detail: + "Something went wrong on our end, so we've refunded this item.", + } as const) + : ({ + animationKey: 'cancelled', + variant: 'cancelled', + label: 'Cancelled', + detail: "You've been refunded for this item.", + } as const) + } + if (item.delaySeconds > 0) { + return { + animationKey: 'delayed', + variant: 'delayed', + label: 'Running late', + detail: `About ${formatDuration(item.delaySeconds)} behind schedule`, + } as const + } + return null +} + +const bannerKey = (item: OrderItemStatusView) => { + const banner = getItemBanner(item) + return banner ? `${item.id}:${banner.animationKey}` : null +} const cancelBlockedReason = (item: OrderItemStatusView) => { - if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') { + if ( + item.cancellable || + item.status === 'cancelled' || + item.status === 'picked_up' + ) { return null } if (item.status === 'ready') { @@ -73,7 +126,9 @@ const StepConnector = ({ filled }: { filled: boolean }) => { className={styles.stepConnectorFill} initial={false} animate={{ scaleY: filled ? 1 : 0 }} - transition={reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }} + transition={ + reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' } + } /> ) @@ -101,8 +156,31 @@ export const OrderStatusView = ({ const [pendingItemId, setPendingItemId] = useState(null) const [error, setError] = useState(null) const [simulating, setSimulating] = useState(false) + // Only banners that appear while the page is open animate in; ones + // already showing on load render straight away. + const [initialBannerKeys] = useState( + () => new Set(status.items.map(bannerKey).filter((key) => key !== null)), + ) + const [expandedItemIds, setExpandedItemIds] = useState>( + new Set(), + ) + const reduceMotion = useReducedMotion() + const collapseTransition = reduceMotion + ? { duration: 0 } + : { duration: 0.4, ease: 'easeInOut' as const } - const runAction = async (orderItemId: number, action: () => Promise) => { + const toggleSteps = (orderItemId: number) => { + setExpandedItemIds((current) => { + const next = new Set(current) + if (!next.delete(orderItemId)) next.add(orderItemId) + return next + }) + } + + const runAction = async ( + orderItemId: number, + action: () => Promise, + ) => { setPendingItemId(orderItemId) setError(null) try { @@ -115,50 +193,25 @@ export const OrderStatusView = ({ } } - // Plays the part of a Machine actually working each Preparation Step, one - // item at a time, so the timeline advances the same way it would from real - // hardware — just compressed in time. Polls fresh status itself rather than - // trusting the `status` prop, since several items advance concurrently and - // each one's steps move independently of the others. - const simulateOrderItem = async (orderItemId: number) => { - for (;;) { - const fresh = await getOrderStatus({ - data: { orderId: status.id, guestAccessToken }, - }) - const item = fresh.items.find((row) => row.id === orderItemId) - if ( - !item || - item.status === 'ready' || - item.status === 'picked_up' || - item.status === 'cancelled' - ) { - return - } - - const currentStep = item.steps.find((step) => step.status !== 'done') - if (!currentStep) return - - if (currentStep.status !== 'in_progress') { - // Queued behind another item on the same Machine Tag — wait for it - // to be assigned rather than failing the whole simulation. - await sleep(400) - continue - } - - await sleep(simulatedStepDelayMs(currentStep.estimatedDurationSeconds)) - await advanceOrderItemStep({ data: { orderItemId } }) - await router.invalidate() - } - } - const runKitchenSimulation = async () => { setSimulating(true) setError(null) try { - const itemIds = status.items - .filter((item) => item.status === 'placed' || item.status === 'paid') - .map((item) => item.id) - await Promise.all(itemIds.map((orderItemId) => simulateOrderItem(orderItemId))) + for (;;) { + await advanceSimulatedKitchen() + const fresh = await getOrderStatus({ + data: { orderId: status.id, guestAccessToken }, + }) + await router.invalidate() + if ( + !fresh.items.some( + (item) => item.status === 'placed' || item.status === 'paid', + ) + ) { + return + } + await sleep(1000) + } } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong.') } finally { @@ -210,42 +263,94 @@ export const OrderStatusView = ({
    {status.items.map((item) => { const cancelHint = cancelBlockedReason(item) + const banner = getItemBanner(item) return (
  • {item.menuItemName} - {item.status}
    {item.options.length > 0 && (

    {item.options.map((option) => option.name).join(', ')}

    )} -
      - {item.steps.map((step) => ( -
    1. + {(!isSettled(item) || expandedItemIds.has(item.id)) && ( + -
    2. - ))} -
    +
      + {item.steps.map((step) => ( +
    1. +
    2. + ))} +
    + + )} + + {banner && ( +
    + + {isSettled(item) && ( + + )} +
    + )} {item.etaSeconds !== null && ( -

    {formatEtaMessage(item.etaSeconds)}

    +

    + {formatEtaMessage(item.etaSeconds)} +

    )}