diff --git a/CONTEXT.md b/CONTEXT.md index bec8486..4095f4a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,7 +16,7 @@ _Avoid_: Store, location (as a separate multi-tenant concept — this project ha The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens. **Order**: -A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up or cancelled. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.) +A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up, cancelled, or discarded. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.) **Charge**: The single payment transaction covering an Order's full total, created once at placement time. Can be partially refunded multiple times over the Order's life — once per Order Item that gets cancelled — rather than each Order Item carrying its own separate payment. @@ -50,7 +50,7 @@ A capability label on a Machine (e.g. "oven", "grill", "assembly") describing wh The set of Preparation Steps awaiting a Machine carrying a given Machine Tag, ordered by turn. When such a Machine becomes idle, it takes the next eligible Step from that Tag's Queue. **Pickup Box**: -A numbered locker at a Kitchen that a ready Order Item is placed in for the customer to collect. Each ready Order Item gets the lowest-numbered free box; if none is free, it waits for the next one to free up. The customer opens the box from their Order and closes it again after taking the item out — closing the box is what marks the Order Item picked up and frees the box. +A numbered locker at a Kitchen that a ready Order Item is placed in for the customer to collect. Each ready Order Item gets the lowest-numbered free box; if none is free, it waits for the next one to free up. The customer opens the box from their Order and closes it again after taking the item out — closing the box is what marks the Order Item picked up and frees the box. The customer has 15 minutes from when the item goes into the box (the pickup window); after that the item is discarded and the box freed, with no refund. A box that is open when the window runs out isn't emptied, since its customer is taking the item out. _Avoid_: Counter, shelf (the box is a lockable compartment the customer operates themselves) **Operator**: diff --git a/drizzle/0010_lush_black_bolt.sql b/drizzle/0010_lush_black_bolt.sql new file mode 100644 index 0000000..f424211 --- /dev/null +++ b/drizzle/0010_lush_black_bolt.sql @@ -0,0 +1 @@ +ALTER TABLE "order_items" ADD COLUMN "placed_in_box_at" timestamp; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..271fc2c --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,954 @@ +{ + "id": "79d9d9d2-6e88-41ed-8d15-160c3549c881", + "prevId": "4826f90c-c779-4b2d-ab6e-491e72dbaee8", + "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 + }, + "pickup_box_id": { + "name": "pickup_box_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placed_in_box_at": { + "name": "placed_in_box_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "picked_up_at": { + "name": "picked_up_at", + "type": "timestamp", + "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" + }, + "order_items_pickup_box_id_pickup_boxes_id_fk": { + "name": "order_items_pickup_box_id_pickup_boxes_id_fk", + "tableFrom": "order_items", + "tableTo": "pickup_boxes", + "columnsFrom": [ + "pickup_box_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.pickup_boxes": { + "name": "pickup_boxes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_open": { + "name": "is_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pickup_boxes_number_unique": { + "name": "pickup_boxes_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "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 11fdbe8..3654cbd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1790888552762, "tag": "0009_damp_ender_wiggin", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1790889640651, + "tag": "0010_lush_black_bolt", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss index 39b0f65..45ab3fd 100644 --- a/src/components/orders/OrderStatusView.module.scss +++ b/src/components/orders/OrderStatusView.module.scss @@ -373,6 +373,7 @@ .pickupButton { overflow: hidden; white-space: nowrap; + transition: opacity 0.3s ease; border-color: $color-text; background: $color-text; color: $color-background; diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index e5c719a..0abba70 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -1,11 +1,12 @@ import { Link, useNavigate, useRouter } from '@tanstack/react-router' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { HoldButton } from '#/components/ui/HoldButton.tsx' import type { OrderStatus } from '#/db/schema.ts' import type { OrderItemStatusView } from '#/domain/ordering.ts' import { useBasket } from '#/lib/basket.tsx' +import { formatCountdown } from '#/lib/format.ts' import { advanceSimulatedKitchen, cancelOrderItem, @@ -23,19 +24,32 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const isSettled = (item: OrderItemStatusView) => item.status === 'ready' || item.status === 'picked_up' || - item.status === 'cancelled' + item.status === 'cancelled' || + item.status === 'discarded' // `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) => { +// `now` is null until the page hydrates, so the server and first client +// render agree and the countdown only appears once it can tick. +const getItemBanner = (item: OrderItemStatusView, now: number | null) => { if (item.status === 'ready') { return { animationKey: 'done', variant: 'ready', label: 'Ready for pickup', - detail: item.pickupBox - ? `Pickup box ${item.pickupBox.number}` - : 'Waiting for a free pickup box', + detail: !item.pickupBox + ? 'Waiting for a free pickup box' + : item.pickupDeadline && now !== null + ? `Pickup box ${item.pickupBox.number} · ${formatCountdown(item.pickupDeadline.getTime() - now)} left` + : `Pickup box ${item.pickupBox.number}`, + } as const + } + if (item.status === 'discarded') { + return { + animationKey: 'discarded', + variant: 'cancelled', + label: 'Not picked up in time', + detail: `${item.pickupBox ? `Pickup box ${item.pickupBox.number}` : 'The pickup box'} was emptied after 15 minutes. This item can't be refunded.`, } as const } if (item.status === 'picked_up') { @@ -68,7 +82,7 @@ const getItemBanner = (item: OrderItemStatusView) => { } const bannerKey = (item: OrderItemStatusView) => { - const banner = getItemBanner(item) + const banner = getItemBanner(item, null) return banner ? `${item.id}:${banner.animationKey}` : null } @@ -116,20 +130,28 @@ const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => ( ) -const LOCK_DURATION_SECONDS = 0.25 const CLOSED_BOX_VISIBLE_MS = 5 * 60 * 1000 // Same commands in both so the shackle morphs: unlocked, the left leg // stretches up from the body and the right leg shrinks to a stub. const LOCKED_SHACKLE = 'M7 11V7a5 5 0 0 1 10 0v4' const UNLOCKED_SHACKLE = 'M7 11V4a5 5 0 0 1 10 0v1' +// Overshoots past the target, so the shackle springs open and snaps shut. +const SHACKLE_EASE = [0.34, 1.8, 0.64, 1] as const + +const pickupButtonLabel = ( + box: { number: number; isOpen: boolean }, + closed: boolean, +) => { + if (closed) return `Box ${box.number} closed` + return box.isOpen + ? `Hold to close box ${box.number}` + : `Hold to open box ${box.number}` +} const LockIcon = ({ open }: { open: boolean }) => { const reduceMotion = useReducedMotion() - const transition = reduceMotion - ? { duration: 0 } - : { duration: LOCK_DURATION_SECONDS, ease: 'easeOut' as const } return ( - { strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" + style={{ originY: 1 }} + initial={false} + animate={ + open + ? { + y: [0, -4, 1, -1.5, 0], + scaleX: [1, 0.94, 1.06, 0.98, 1], + scaleY: [1, 1.1, 0.92, 1.03, 1], + } + : { + y: [0, 2, -3.5, 1, -1, 0], + scaleX: [1, 1.14, 0.94, 1.04, 0.99, 1], + scaleY: [1, 0.8, 1.1, 0.96, 1.02, 1], + } + } + transition={ + reduceMotion ? { duration: 0 } : { duration: 0.65, ease: 'easeOut' } + } > - + ) } @@ -194,10 +236,22 @@ export const OrderStatusView = ({ const [initialBannerKeys] = useState( () => new Set(status.items.map(bannerKey).filter((key) => key !== null)), ) - // A closed box's button stays on screen, disabled and locked, for a while - // after pickup; re-render when the next one is due to animate out. + // Ticks every second while a pickup countdown runs; otherwise only wakes + // when a closed box's button (kept for a while after pickup) is due to go. const [now, setNow] = useState(() => Date.now()) + const [hydrated, setHydrated] = useState(false) + useEffect(() => setHydrated(true), []) + const hasPickupCountdown = status.items.some( + (item) => item.pickupDeadline !== null, + ) useEffect(() => { + if (!hasPickupCountdown) return + setNow(Date.now()) + const interval = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(interval) + }, [hasPickupCountdown]) + useEffect(() => { + if (hasPickupCountdown) return const hideAt = status.items .flatMap((item) => item.pickedUpAt @@ -211,7 +265,22 @@ export const OrderStatusView = ({ Math.min(...hideAt) - Date.now(), ) return () => clearTimeout(timeout) - }, [status.items, now]) + }, [status.items, now, hasPickupCountdown]) + // Once a countdown runs out, reload so the server can empty the box and the + // item shows as discarded. Only once per item: an open box isn't emptied. + const refreshedExpiredItemIds = useRef(new Set()) + useEffect(() => { + const newlyExpired = status.items.filter( + (item) => + item.pickupDeadline !== null && + item.pickupDeadline.getTime() <= now && + !refreshedExpiredItemIds.current.has(item.id), + ) + if (newlyExpired.length === 0) return + for (const item of newlyExpired) + refreshedExpiredItemIds.current.add(item.id) + void router.invalidate() + }, [status.items, now, router]) const [expandedItemIds, setExpandedItemIds] = useState>( new Set(), ) @@ -343,7 +412,7 @@ export const OrderStatusView = ({ {error &&

{error}

}