diff --git a/drizzle/0009_damp_ender_wiggin.sql b/drizzle/0009_damp_ender_wiggin.sql new file mode 100644 index 0000000..d989dd4 --- /dev/null +++ b/drizzle/0009_damp_ender_wiggin.sql @@ -0,0 +1 @@ +ALTER TABLE "order_items" ADD COLUMN "picked_up_at" timestamp; \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..955f68d --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,948 @@ +{ + "id": "4826f90c-c779-4b2d-ab6e-491e72dbaee8", + "prevId": "42d1f3c1-18f8-42a3-ac41-9e29a4b14add", + "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 + }, + "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 5812ce8..11fdbe8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1790845995013, "tag": "0008_absurd_the_anarchist", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1790888552762, + "tag": "0009_damp_ender_wiggin", + "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 27cad64..39b0f65 100644 --- a/src/components/orders/OrderStatusView.module.scss +++ b/src/components/orders/OrderStatusView.module.scss @@ -94,10 +94,81 @@ .itemHeader { display: flex; justify-content: space-between; + align-items: center; font-weight: 600; margin-bottom: $space-2; } +.menuButton { + display: inline-flex; + flex-shrink: 0; + margin: (-$space-2) (-$space-2) (-$space-2) $space-2; + padding: $space-2; + border: none; + border-radius: $radius; + background: none; + color: $color-text-muted; + cursor: pointer; + + svg { + width: 1.25rem; + height: 1.25rem; + stroke-width: 3; + } + + &:hover { + color: $color-text; + } +} + +.menu { + position: fixed; + position-area: bottom span-left; + position-try-fallbacks: flip-block; + inset: auto; + margin: $space-1 0; + padding: $space-1; + min-width: 160px; + border: 1px solid $color-border; + border-radius: $radius; + background: $color-surface; + color: $color-text; + font-weight: 400; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.menuItem { + display: flex; + align-items: center; + gap: $space-2; + width: 100%; + padding: $space-2 $space-3; + border: none; + border-radius: calc(#{$radius} - 2px); + background: none; + color: inherit; + font: inherit; + font-size: 0.9rem; + text-align: left; + text-decoration: none; + cursor: pointer; + + &:hover:not(:disabled) { + background: $color-background; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + } +} + .itemOptions { margin: 0 0 $space-2; font-size: 0.85rem; diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index 0bbac9a..e5c719a 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -1,6 +1,6 @@ import { Link, useNavigate, useRouter } from '@tanstack/react-router' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { HoldButton } from '#/components/ui/HoldButton.tsx' import type { OrderStatus } from '#/db/schema.ts' @@ -43,6 +43,9 @@ const getItemBanner = (item: OrderItemStatusView) => { animationKey: 'done', variant: 'pickedUp', label: 'Picked up', + detail: item.pickupBox + ? `Pickup box ${item.pickupBox.number}` + : undefined, } as const } if (item.status === 'cancelled') { @@ -93,6 +96,7 @@ const actionIconPaths = { 'M12 17h.01', ], cancel: ['M18 6 6 18', 'M6 6l12 12'], + more: ['M12 12h.01', 'M19 12h.01', 'M5 12h.01'], } const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => ( @@ -113,13 +117,12 @@ 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' -// The shackle lifts while the box is open, and drops back first when the -// button leaves after closing, so the lock visibly snaps shut. const LockIcon = ({ open }: { open: boolean }) => { const reduceMotion = useReducedMotion() const transition = reduceMotion @@ -140,7 +143,6 @@ const LockIcon = ({ open }: { open: boolean }) => { @@ -192,6 +194,24 @@ 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. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const hideAt = status.items + .flatMap((item) => + item.pickedUpAt + ? [item.pickedUpAt.getTime() + CLOSED_BOX_VISIBLE_MS] + : [], + ) + .filter((time) => time > now) + if (hideAt.length === 0) return + const timeout = setTimeout( + () => setNow(Date.now()), + Math.min(...hideAt) - Date.now(), + ) + return () => clearTimeout(timeout) + }, [status.items, now]) const [expandedItemIds, setExpandedItemIds] = useState>( new Set(), ) @@ -246,11 +266,7 @@ export const OrderStatusView = ({ marginTop: 0, marginRight: -8, marginBottom: -8, - transition: { - delay: LOCK_DURATION_SECONDS + 0.1, - duration: 0.3, - ease: 'easeInOut' as const, - }, + transition: { duration: 0.3, ease: 'easeInOut' as const }, } const togglePickupBox = (item: OrderItemStatusView) => { @@ -328,10 +344,58 @@ export const OrderStatusView = ({ {status.items.map((item) => { const banner = getItemBanner(item) + const boxClosed = item.status !== 'ready' + const pickupBox = + item.status === 'ready' || + (item.pickedUpAt !== null && + now - item.pickedUpAt.getTime() < CLOSED_BOX_VISIBLE_MS) + ? item.pickupBox + : null return ( {item.menuItemName} + + + + + buyAgain(item)} + > + + Buy again + + + + Get help + + {item.options.length > 0 && ( @@ -396,7 +460,7 @@ export const OrderStatusView = ({ ) } /> - {isSettled(item) && ( + {item.status === 'cancelled' && ( - {item.pickupBox && ( + {pickupBox && ( void runAction(item.id, () => togglePickupBox(item)) } exit={pickupButtonExit} > - - {item.pickupBox.isOpen - ? `Hold to close box ${item.pickupBox.number}` - : `Hold to open box ${item.pickupBox.number}`} + + {boxClosed + ? `Box ${pickupBox.number} closed` + : pickupBox.isOpen + ? `Hold to close box ${pickupBox.number}` + : `Hold to open box ${pickupBox.number}`} )} - buyAgain(item)} - > - - Buy again - - - - - Get help - {item.cancellable && ( (), // Set once the item is ready and a Pickup Box is free; kept after pickup. pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id), + pickedUpAt: timestamp('picked_up_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }) diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index 00ffa49..ab23757 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -880,7 +880,7 @@ export const closePickupBox = async ( .where(eq(schema.pickupBoxes.id, box.id)) await tx .update(schema.orderItems) - .set({ status: 'picked_up' }) + .set({ status: 'picked_up', pickedUpAt: new Date() }) .where(eq(schema.orderItems.id, orderItemId)) await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId) @@ -908,8 +908,10 @@ export type OrderItemStatusView = { // Menu Item recreates its Options under new ids, so the ids at order time // can't be reused. Options since removed from the menu are left out. buyAgainOptionIds: Array - // Only set while the item is ready and waiting in a box. + // The box a ready item waits in, still reported once it's picked up (with + // isOpen false, since the box may since have gone to another item). pickupBox: { number: number; isOpen: boolean } | null + pickedUpAt: Date | null etaSeconds: number | null } @@ -950,7 +952,7 @@ export const getOrderStatus = async ( .where(inArray(schema.orderItemOptions.orderItemId, orderItemIds)) const pickupBoxIds = items - .filter((item) => item.status === 'ready') + .filter((item) => item.status === 'ready' || item.status === 'picked_up') .map((item) => item.pickupBoxId) .filter((id) => id !== null) const pickupBoxesById = new Map( @@ -1142,7 +1144,8 @@ export const getOrderStatus = async ( (option) => option.orderItemId === item.id, ) const pickupBox = - item.status === 'ready' && item.pickupBoxId !== null + (item.status === 'ready' || item.status === 'picked_up') && + item.pickupBoxId !== null ? pickupBoxesById.get(item.pickupBoxId) : undefined @@ -1170,8 +1173,12 @@ export const getOrderStatus = async ( ) .map((current) => current.id), pickupBox: pickupBox - ? { number: pickupBox.number, isOpen: pickupBox.isOpen } + ? { + number: pickupBox.number, + isOpen: item.status === 'ready' && pickupBox.isOpen, + } : null, + pickedUpAt: item.pickedUpAt, etaSeconds, } }) diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts index 625d996..bb861d0 100644 --- a/tests/ordering.test.ts +++ b/tests/ordering.test.ts @@ -639,7 +639,8 @@ describe('pickup boxes', () => { await ordering.closePickupBox(testDb.db, itemId, accessFor(order)) const status = await getStatus(order) expect(status.items[0].status).toBe('picked_up') - expect(status.items[0].pickupBox).toBeNull() + expect(status.items[0].pickupBox).toEqual({ number: 1, isOpen: false }) + expect(status.items[0].pickedUpAt).toBeInstanceOf(Date) expect(status.status).toBe('complete') })
@@ -396,7 +460,7 @@ export const OrderStatusView = ({ ) } /> - {isSettled(item) && ( + {item.status === 'cancelled' && ( - {item.pickupBox && ( + {pickupBox && ( void runAction(item.id, () => togglePickupBox(item)) } exit={pickupButtonExit} > - - {item.pickupBox.isOpen - ? `Hold to close box ${item.pickupBox.number}` - : `Hold to open box ${item.pickupBox.number}`} + + {boxClosed + ? `Box ${pickupBox.number} closed` + : pickupBox.isOpen + ? `Hold to close box ${pickupBox.number}` + : `Hold to open box ${pickupBox.number}`} )} - buyAgain(item)} - > - - Buy again - - - - - Get help - {item.cancellable && ( (), // Set once the item is ready and a Pickup Box is free; kept after pickup. pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id), + pickedUpAt: timestamp('picked_up_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }) diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index 00ffa49..ab23757 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -880,7 +880,7 @@ export const closePickupBox = async ( .where(eq(schema.pickupBoxes.id, box.id)) await tx .update(schema.orderItems) - .set({ status: 'picked_up' }) + .set({ status: 'picked_up', pickedUpAt: new Date() }) .where(eq(schema.orderItems.id, orderItemId)) await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId) @@ -908,8 +908,10 @@ export type OrderItemStatusView = { // Menu Item recreates its Options under new ids, so the ids at order time // can't be reused. Options since removed from the menu are left out. buyAgainOptionIds: Array - // Only set while the item is ready and waiting in a box. + // The box a ready item waits in, still reported once it's picked up (with + // isOpen false, since the box may since have gone to another item). pickupBox: { number: number; isOpen: boolean } | null + pickedUpAt: Date | null etaSeconds: number | null } @@ -950,7 +952,7 @@ export const getOrderStatus = async ( .where(inArray(schema.orderItemOptions.orderItemId, orderItemIds)) const pickupBoxIds = items - .filter((item) => item.status === 'ready') + .filter((item) => item.status === 'ready' || item.status === 'picked_up') .map((item) => item.pickupBoxId) .filter((id) => id !== null) const pickupBoxesById = new Map( @@ -1142,7 +1144,8 @@ export const getOrderStatus = async ( (option) => option.orderItemId === item.id, ) const pickupBox = - item.status === 'ready' && item.pickupBoxId !== null + (item.status === 'ready' || item.status === 'picked_up') && + item.pickupBoxId !== null ? pickupBoxesById.get(item.pickupBoxId) : undefined @@ -1170,8 +1173,12 @@ export const getOrderStatus = async ( ) .map((current) => current.id), pickupBox: pickupBox - ? { number: pickupBox.number, isOpen: pickupBox.isOpen } + ? { + number: pickupBox.number, + isOpen: item.status === 'ready' && pickupBox.isOpen, + } : null, + pickedUpAt: item.pickedUpAt, etaSeconds, } }) diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts index 625d996..bb861d0 100644 --- a/tests/ordering.test.ts +++ b/tests/ordering.test.ts @@ -639,7 +639,8 @@ describe('pickup boxes', () => { await ordering.closePickupBox(testDb.db, itemId, accessFor(order)) const status = await getStatus(order) expect(status.items[0].status).toBe('picked_up') - expect(status.items[0].pickupBox).toBeNull() + expect(status.items[0].pickupBox).toEqual({ number: 1, isOpen: false }) + expect(status.items[0].pickedUpAt).toBeInstanceOf(Date) expect(status.status).toBe('complete') })