diff --git a/.env.example b/.env.example index 39d25b5..7c4bd57 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/drizzle/0004_misty_hedge_knight.sql b/drizzle/0004_misty_hedge_knight.sql new file mode 100644 index 0000000..29db0d9 --- /dev/null +++ b/drizzle/0004_misty_hedge_knight.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..0a761da --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 05f2390..396ba37 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/public/burger.png b/public/burger.png new file mode 100644 index 0000000..91165c7 Binary files /dev/null and b/public/burger.png differ diff --git a/public/pizza.png b/public/pizza.png new file mode 100644 index 0000000..f146130 Binary files /dev/null and b/public/pizza.png differ diff --git a/src/components/auth/SsoButtons.module.scss b/src/components/auth/SsoButtons.module.scss index 5e3d8b9..2737320 100644 --- a/src/components/auth/SsoButtons.module.scss +++ b/src/components/auth/SsoButtons.module.scss @@ -26,7 +26,7 @@ border: 1px solid $color-border; &:hover { - background: $color-surface; + background: $color-border; } } diff --git a/src/components/basket/BasketLink.tsx b/src/components/basket/BasketLink.tsx index 086a253..f7071ef 100644 --- a/src/components/basket/BasketLink.tsx +++ b/src/components/basket/BasketLink.tsx @@ -7,7 +7,7 @@ import styles from './BasketLink.module.scss' const MotionLink = motion.create(Link) -const BasketIcon = () => ( +export const BasketIcon = () => ( + items: Array<{ menuItemId: number; selectedOptionIds?: Array }> disabled: boolean onPlaced: (order: PlacedOrder) => void } diff --git a/src/components/ordering/MenuItemCustomizer.module.scss b/src/components/ordering/MenuItemCustomizer.module.scss new file mode 100644 index 0000000..1fab78d --- /dev/null +++ b/src/components/ordering/MenuItemCustomizer.module.scss @@ -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; +} diff --git a/src/components/ordering/MenuItemCustomizer.tsx b/src/components/ordering/MenuItemCustomizer.tsx new file mode 100644 index 0000000..4a97ed0 --- /dev/null +++ b/src/components/ordering/MenuItemCustomizer.tsx @@ -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 + onAdd: (menuItemId: number, optionIds: Array) => void + onRemove: (menuItemId: number, optionIds: Array) => void +} + +const sortedKey = (optionIds: Array) => + [...optionIds].sort((a, b) => a - b).join(',') + +export const MenuItemCustomizer = ({ + item, + lines, + onAdd, + onRemove, +}: MenuItemCustomizerProps) => { + const [checkedOptionIds, setCheckedOptionIds] = useState>([]) + + 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 ( +
+

Customize

+
+ {item.options.map((option) => ( + + ))} +
+ +
+ {formatPrice(unitPriceCents)} +
+ + + +
+
+
+ ) +} diff --git a/src/components/ordering/MenuItemDetail.module.scss b/src/components/ordering/MenuItemDetail.module.scss new file mode 100644 index 0000000..faa3529 --- /dev/null +++ b/src/components/ordering/MenuItemDetail.module.scss @@ -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; +} diff --git a/src/components/ordering/MenuItemDetail.tsx b/src/components/ordering/MenuItemDetail.tsx new file mode 100644 index 0000000..007e2be --- /dev/null +++ b/src/components/ordering/MenuItemDetail.tsx @@ -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 + onAdd: (menuItemId: number, optionIds?: Array) => void + onRemove: (menuItemId: number, optionIds?: Array) => void +} + +export const MenuItemDetail = ({ + item, + lines, + onAdd, + onRemove, +}: MenuItemDetailProps) => { + const totalSeconds = item.steps.reduce( + (sum, step) => sum + step.estimatedDurationSeconds, + 0, + ) + + return ( +
+ + +
+

{item.name}

+ {item.description && ( +

{item.description}

+ )} +

+ {formatPrice(item.priceCents)} · {formatDuration(totalSeconds)} +

+
+ + +
+ ) +} diff --git a/src/components/ordering/MenuItemThumbnail.module.scss b/src/components/ordering/MenuItemThumbnail.module.scss new file mode 100644 index 0000000..f192532 --- /dev/null +++ b/src/components/ordering/MenuItemThumbnail.module.scss @@ -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; +} diff --git a/src/components/ordering/MenuItemThumbnail.tsx b/src/components/ordering/MenuItemThumbnail.tsx new file mode 100644 index 0000000..67eed0b --- /dev/null +++ b/src/components/ordering/MenuItemThumbnail.tsx @@ -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 = () => ( + + + + + +) + +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 ( + + ) +} diff --git a/src/components/ordering/MenuList.module.scss b/src/components/ordering/MenuList.module.scss index bfcfa4c..13c9b1c 100644 --- a/src/components/ordering/MenuList.module.scss +++ b/src/components/ordering/MenuList.module.scss @@ -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; diff --git a/src/components/ordering/MenuList.tsx b/src/components/ordering/MenuList.tsx index 33dbfd4..a63d178 100644 --- a/src/components/ordering/MenuList.tsx +++ b/src/components/ordering/MenuList.tsx @@ -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 - quantities: Record - onAdd: (menuItemId: number) => void - onRemove: (menuItemId: number) => void + lines: Array + onAdd: (menuItemId: number, optionIds?: Array) => void + onRemove: (menuItemId: number, optionIds?: Array) => 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) => (
    {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 ( -
  • + const info = ( + <> +

    {item.name}

    {item.description && ( @@ -42,25 +47,46 @@ export const MenuList = ({ {formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}

    -
    - - - + + ) + + return ( +
  • +
    + {hasOptions ? ( + + {info} + + ) : ( +
    {info}
    + )} +
    + + + +
  • ) diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss index 0552686..21c160d 100644 --- a/src/components/orders/OrderStatusView.module.scss +++ b/src/components/orders/OrderStatusView.module.scss @@ -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; diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index 7ce967f..d03a41e 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -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 = () => ( ( ) +const InfoIcon = () => ( + +) + export type OrderStatusResult = { id: number status: OrderStatus + createdAt: Date items: Array } @@ -73,87 +101,128 @@ export const OrderStatusView = ({

    Order #{status.id}

    Status: {status.status}

    +

    + Placed{' '} + {status.createdAt.toLocaleDateString([], { + month: 'short', + day: 'numeric', + })} + {' at '} + {status.createdAt.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +

    {error &&

    {error}

    }
      - {status.items.map((item) => ( -
    • -
      - {item.menuItemName} - {item.status} -
      -
        - {item.steps.map((step) => ( -
      1. -
      2. - ))} -
      - {item.etaSeconds !== null && ( -

      {formatEtaMessage(item.etaSeconds)}

      - )} -
      - - - - {item.status === 'ready' && ( - - )} - Get help - {item.cancellable && ( + + {item.status === 'ready' && ( + )} + Get help + {cancelHint && ( + <> + + + + )} + {item.cancellable && ( + - )} -
      -
    • - ))} + )} + + + ) + })}
    ) diff --git a/src/components/orders/OrdersListView.tsx b/src/components/orders/OrdersListView.tsx index cf6bc6a..5c568c1 100644 --- a/src/components/orders/OrdersListView.tsx +++ b/src/components/orders/OrdersListView.tsx @@ -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 } -const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}` - const formatItems = (items: CustomerOrderSummary['items']) => items .map(({ menuItemName, quantity }) => diff --git a/src/db/schema.ts b/src/db/schema.ts index 289a6e5..3485abe 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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', diff --git a/src/db/seed.ts b/src/db/seed.ts index 70dbe2c..10e217d 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -100,6 +100,15 @@ export const seedGhostKitchen = async (db: NodePgDatabase) => { }, ]) + 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) => { }, ]) + 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) => { }, machines: { prep1, oven1, grill1, assembly1 }, menuItems: { pizza, burger }, + menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions }, } } diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index 6290f78..af152db 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -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 }> 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() - 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, } } diff --git a/src/lib/basket.tsx b/src/lib/basket.tsx index 93b57df..c61caef 100644 --- a/src/lib/basket.tsx +++ b/src/lib/basket.tsx @@ -9,71 +9,126 @@ import type { ReactNode } from 'react' const STORAGE_KEY = 'ghost-kitchen-basket' +export type BasketLine = { + menuItemId: number + optionIds: Array + quantity: number +} + +type StoredLines = Partial> + +// 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) => + `${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 + lines: Array itemCount: number - add: (menuItemId: number) => void - remove: (menuItemId: number) => void + add: (menuItemId: number, optionIds?: Array) => void + remove: (menuItemId: number, optionIds?: Array) => void clear: () => void } const BasketContext = createContext(null) -const readStoredQuantities = (): Record => { +const readStoredLines = (): StoredLines => { try { const raw = window.localStorage.getItem(STORAGE_KEY) - return raw ? (JSON.parse(raw) as Record) : {} + 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` 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>({}) + const [storedLines, setStoredLines] = useState({}) // 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 = []) => { + 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 = []) => { + 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 ( diff --git a/src/lib/format.ts b/src/lib/format.ts index 7107d3c..b589142 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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) diff --git a/src/lib/payment.ts b/src/lib/payment.ts index 6490739..d0e922b 100644 --- a/src/lib/payment.ts +++ b/src/lib/payment.ts @@ -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: { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index adb42ad..1f4e273 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/basket.module.scss b/src/routes/basket.module.scss index 582eabb..648a789 100644 --- a/src/routes/basket.module.scss +++ b/src/routes/basket.module.scss @@ -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 { diff --git a/src/routes/basket.tsx b/src/routes/basket.tsx index b810102..d1b0203 100644 --- a/src/routes/basket.tsx +++ b/src/routes/basket.tsx @@ -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) => + 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 (

    Your basket

    - {lines.length === 0 ? ( -

    - Your basket is empty — browse the menu to add - something. -

    - ) : ( - <> -
      - {lines.map(({ item, quantity }) => ( -
    • -
      - {item.name} - - ${((item.priceCents * quantity) / 100).toFixed(2)} - -
      -
      - - - -
      -
    • - ))} -
    +
    + + {isEmpty ? ( + + +

    Your basket is empty

    +

    + Add something tasty from the menu to get started. +

    + + Browse the menu + +
    + ) : ( + +
      + {resolvedLines.map(({ line, item, unitPriceCents }) => { + const names = optionNames(item, line.optionIds) + return ( +
    • +
      + {item.name} + {names.length > 0 && ( +
        + {names.map((name) => ( +
      • {name}
      • + ))} +
      + )} + + {formatPrice(unitPriceCents * line.quantity)} + +
      +
      + + + +
      +
    • + ) + })} +
    -
    - Total - ${(totalCents / 100).toFixed(2)} -
    +
    + Total + {formatPrice(totalCents)} +
    - { - await navigate({ - to: '/orders/$orderId', - params: { orderId: String(order.id) }, - search: order.guestAccessToken - ? { token: order.guestAccessToken } - : {}, - }) - clear() - }} - /> - - )} + { + await navigate({ + to: '/orders/$orderId', + params: { orderId: String(order.id) }, + search: order.guestAccessToken + ? { token: order.guestAccessToken } + : {}, + }) + clear() + }} + /> +
    + )} +
    +
    ) } diff --git a/src/routes/index.module.scss b/src/routes/index.module.scss index 768c71a..e09c6ee 100644 --- a/src/routes/index.module.scss +++ b/src/routes/index.module.scss @@ -5,3 +5,8 @@ margin: 0 auto; padding: $space-5 $space-3; } + +.intro { + margin: 0 0 $space-4; + color: $color-text-muted; +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index fcf265b..61a370a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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 (
    - +

    + Zero cooks, all machine. A robotic kitchen grills, assembles, and + plates your order in real time — watch it happen live, from click to + pickup. +

    +
    ) } diff --git a/src/routes/items/$menuItemId.module.scss b/src/routes/items/$menuItemId.module.scss new file mode 100644 index 0000000..cea19ff --- /dev/null +++ b/src/routes/items/$menuItemId.module.scss @@ -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; +} diff --git a/src/routes/items/$menuItemId.tsx b/src/routes/items/$menuItemId.tsx new file mode 100644 index 0000000..00a5425 --- /dev/null +++ b/src/routes/items/$menuItemId.tsx @@ -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 = () => ( + +) + +const MenuItemRoute = () => { + const { item } = Route.useLoaderData() + const { lines, add, remove } = useBasket() + + return ( +
    + + + Back to menu + + line.menuItemId === item.id)} + onAdd={add} + onRemove={remove} + /> +
    + ) +} + +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, +}) diff --git a/src/routes/login.module.scss b/src/routes/login.module.scss index 1326839..cb9690f 100644 --- a/src/routes/login.module.scss +++ b/src/routes/login.module.scss @@ -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; diff --git a/src/routes/signup.module.scss b/src/routes/signup.module.scss index 1326839..cb9690f 100644 --- a/src/routes/signup.module.scss +++ b/src/routes/signup.module.scss @@ -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; diff --git a/src/server/orders.ts b/src/server/orders.ts index 3e0f109..f54f4e4 100644 --- a/src/server/orders.ts +++ b/src/server/orders.ts @@ -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, }), ) diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts index f1820f5..8f3229e 100644 --- a/tests/ordering.test.ts +++ b/tests/ordering.test.ts @@ -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