From 7e95a558b4e2f5de58e4b966008da1f73f9c91dd Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:32:04 +0200 Subject: [PATCH 1/8] feat(restaurant): checkout + order placement + status polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end customer order flow against the restaurant extension. composables/useOrder(orderId) — polls GET /orders/{id} every orderPollMs (5s default) while status is non-terminal. Refetches immediately on VisibilityService.onVisible so a backgrounded tab catches up on resume. Cleans the interval on scope dispose. KNOWN_ORDER_STATUSES is the closed list; the type stays open so new statuses from aiolabs/restaurant#4 land without breaking. composables/useCheckout() — orchestrates the full flow: 1. quoteOrder per restaurant in the cart 2. pre-flight balance check (wallet.balance.value, sat -> msat) 3. placeOrder per restaurant -> { order, invoice } 4. WalletService.sendPayment(bolt11) per invoice 5. clearRestaurant(rid) on success buildCreateOrder is the single point CreateOrder is constructed; loyalty (aiolabs/restaurant#5) and NIP-17 transport (#9) both plug in here without touching the rest of the flow. components/OrderInvoiceCard.vue — bolt11 QR via qrcode lib, copy-to-clipboard, expires-in countdown. White-bg QR for scanner contrast regardless of theme (pure UX call — humans don't read QRs, cameras do). views/CheckoutPage.vue — review + total + 'Pay & place order' CTA. Progress indicator shows current restaurant + N of M during the multi-restaurant loop. Empty-cart guard redirects to /cart. On success, stores placed orders in 'restaurant.lastOrders.v1' (capped at 50, newest first) and navigates to /orders/. views/OrderStatusPage.vue — status pill with semantic Badge variants, conditional bolt11 QR when status='pending', success alert when paid/accepted/ready, line items with modifier summary, timeline of transitions, money breakdown (subtotal / tax / tip / total). Polls live via useOrder. Routes added: /checkout, /orders/:id. Money convention: cart.unit_msat stores per-unit values directly from MenuItem.price (declared currency, not msat). The extension itself msat-ifies amounts on POST /orders/quote and /orders. The checkout's pre-flight balance check converts wallet sats -> msat before comparing to the quote's required_msat. Display strings divide by 1000 only when reading order.*_msat fields back from the extension. Design: shadcn-vue throughout (Alert, Badge, Button, Card, Separator) + Tailwind 4 + theme-aware semantic classes (bg-card, text-foreground, text-muted-foreground, text-primary, text-destructive, border-border, with the one exception of the QR card's white background, justified inline). Verified: vue-tsc -b clean. --- .../components/OrderInvoiceCard.vue | 104 +++++++ .../restaurant/composables/useCheckout.ts | 225 +++++++++++++++ .../restaurant/composables/useOrder.ts | 142 ++++++++++ src/modules/restaurant/index.ts | 12 + src/modules/restaurant/views/CheckoutPage.vue | 186 +++++++++++++ .../restaurant/views/OrderStatusPage.vue | 257 ++++++++++++++++++ 6 files changed, 926 insertions(+) create mode 100644 src/modules/restaurant/components/OrderInvoiceCard.vue create mode 100644 src/modules/restaurant/composables/useCheckout.ts create mode 100644 src/modules/restaurant/composables/useOrder.ts create mode 100644 src/modules/restaurant/views/CheckoutPage.vue create mode 100644 src/modules/restaurant/views/OrderStatusPage.vue diff --git a/src/modules/restaurant/components/OrderInvoiceCard.vue b/src/modules/restaurant/components/OrderInvoiceCard.vue new file mode 100644 index 0000000..64d613b --- /dev/null +++ b/src/modules/restaurant/components/OrderInvoiceCard.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/modules/restaurant/composables/useCheckout.ts b/src/modules/restaurant/composables/useCheckout.ts new file mode 100644 index 0000000..c6ea72b --- /dev/null +++ b/src/modules/restaurant/composables/useCheckout.ts @@ -0,0 +1,225 @@ +/** + * useCheckout — orchestrates the place-order + pay-bolt11 sequence + * for every restaurant currently in the cart. + * + * v1 ships REST-only: + * for each restaurant in the cart: + * 1. quote (msat required) + * 2. balance pre-check (sum across all restaurants) + * 3. placeOrder → { order, invoice } + * 4. WalletService.sendPayment(bolt11) + * + * The festival aggregator (aiolabs/restaurant#8) exercises this + * same path with N > 1 restaurants in the cart. v1 happens to ship + * a UI where N == 1, but the orchestration is multi-restaurant + * already. + * + * NIP-17 transport (aiolabs/restaurant#9) plugs in here later as a + * `transport: 'rest' | 'nostr'` option that gift-wraps the + * CreateOrder instead of POSTing it. The `buildCreateOrder` + * helper is the single point both transports build through, so + * adding loyalty (aiolabs/restaurant#5) is also a one-function + * change rather than touching every call site. + */ +import { ref, type Ref } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import { useAuth } from '@/composables/useAuthService' +import type WalletService from '@/modules/wallet/services/WalletService' +import type { RestaurantAPI } from '../services/RestaurantAPI' +import { useCartStore, type CartLine } from '../stores/cart' +import type { + CreateOrder, + CreateOrderItem, + Order, + OrderInvoice, + PlaceOrderResponse, +} from '../types/restaurant' + +export interface PlacedOrder { + restaurantId: string + restaurantSlug: string + order: Order + invoice: OrderInvoice | null +} + +export interface CheckoutResult { + placedOrders: PlacedOrder[] + paidPaymentHashes: string[] +} + +export interface CheckoutState { + step: 'idle' | 'quoting' | 'placing' | 'paying' | 'done' | 'error' + progress: { current: number; total: number } + currentRestaurantSlug: string | null + error: string | null +} + +export interface UseCheckoutReturn { + state: Ref + checkout: () => Promise +} + +/** + * The single point CreateOrder is built — keeps loyalty (#5), NIP-17 + * transport (#9), and tip overrides one-place changes rather than + * touching the whole flow. Today loyalty is unconfigured so the + * extra block stays at its defaults. + */ +function buildCreateOrder( + restaurantId: string, + customerPubkey: string | undefined, + lines: CartLine[] +): CreateOrder { + const items: CreateOrderItem[] = lines.map((l) => ({ + menu_item_id: l.menu_item_id, + quantity: l.quantity, + selected_modifiers: l.selected_modifiers, + note: l.note ?? undefined, + })) + + // Loyalty (#5) future-extension point: when implemented, inject + // { loyalty_credits_msat, loyalty_pubkey } into extra.fields here. + return { + restaurant_id: restaurantId, + customer_pubkey: customerPubkey || null, + items, + channel: 'rest', + payment_method: 'lightning', + } +} + +export function useCheckout(): UseCheckoutReturn { + const api = injectService(SERVICE_TOKENS.RESTAURANT_API) + const wallet = injectService(SERVICE_TOKENS.WALLET_SERVICE) + const cart = useCartStore() + const { user } = useAuth() + + const state = ref({ + step: 'idle', + progress: { current: 0, total: 0 }, + currentRestaurantSlug: null, + error: null, + }) + + async function checkout(): Promise { + const buckets = cart.restaurantsInCart.map((rid) => ({ + restaurantId: rid, + restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '', + lines: cart.linesFor(rid), + })) + + if (!buckets.length) { + throw new Error('Cart is empty') + } + + state.value = { + step: 'quoting', + progress: { current: 0, total: buckets.length }, + currentRestaurantSlug: null, + error: null, + } + + // 1. Quote per restaurant. + const quotes: Array<{ + restaurantId: string + restaurantSlug: string + lines: CartLine[] + msat: number + }> = [] + for (let i = 0; i < buckets.length; i++) { + const b = buckets[i] + state.value.currentRestaurantSlug = b.restaurantSlug + state.value.progress = { current: i, total: buckets.length } + const quote = await api.quoteOrder( + b.lines.map((l) => ({ + menu_item_id: l.menu_item_id, + quantity: l.quantity, + selected_modifiers: l.selected_modifiers, + note: l.note ?? undefined, + })) + ) + quotes.push({ ...b, msat: quote.required_msat }) + } + + // 2. Pre-flight balance check. The webapp's WalletService + // exposes balance via PaymentService; we ask the service for + // the current cached value via its public computed. + // NOTE: balance values in this codebase are sats, not msat — + // convert before comparing. + const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0) + const balanceSat = ( + wallet as unknown as { balance?: { value?: number } } + ).balance?.value + if (typeof balanceSat === 'number') { + const balanceMsat = balanceSat * 1000 + if (balanceMsat < totalMsatRequired) { + state.value = { + step: 'error', + progress: { current: 0, total: buckets.length }, + currentRestaurantSlug: null, + error: `Insufficient balance. Need ${Math.ceil(totalMsatRequired / 1000)} sat, have ${balanceSat} sat.`, + } + throw new Error(state.value.error!) + } + } + + // 3. Place orders. + state.value.step = 'placing' + const placed: PlacedOrder[] = [] + for (let i = 0; i < quotes.length; i++) { + const q = quotes[i] + state.value.currentRestaurantSlug = q.restaurantSlug + state.value.progress = { current: i, total: quotes.length } + const payload = buildCreateOrder( + q.restaurantId, + user.value?.pubkey, + q.lines + ) + const result: PlaceOrderResponse = await api.placeOrder(payload) + placed.push({ + restaurantId: q.restaurantId, + restaurantSlug: q.restaurantSlug, + order: result.order, + invoice: result.invoice, + }) + } + + // 4. Pay each bolt11 sequentially. If a payment fails, the + // earlier successes are still placed-and-paid — best-effort + // is the v1 model (see plan; HODL atomicity is a future + // issue). + state.value.step = 'paying' + const paidHashes: string[] = [] + for (let i = 0; i < placed.length; i++) { + const p = placed[i] + state.value.currentRestaurantSlug = p.restaurantSlug + state.value.progress = { current: i, total: placed.length } + if (!p.invoice) continue // cash orders skip payment + + const success = await wallet.sendPayment({ + amount: Math.ceil(p.invoice.amount_msat / 1000), + destination: p.invoice.bolt11, + comment: `Order at ${p.restaurantSlug}`, + }) + if (success && p.invoice.payment_hash) { + paidHashes.push(p.invoice.payment_hash) + } + } + + // 5. Clear the paid lines from the cart. + for (const p of placed) { + cart.clearRestaurant(p.restaurantId) + } + + state.value = { + step: 'done', + progress: { current: placed.length, total: placed.length }, + currentRestaurantSlug: null, + error: null, + } + + return { placedOrders: placed, paidPaymentHashes: paidHashes } + } + + return { state, checkout } +} diff --git a/src/modules/restaurant/composables/useOrder.ts b/src/modules/restaurant/composables/useOrder.ts new file mode 100644 index 0000000..423ffd2 --- /dev/null +++ b/src/modules/restaurant/composables/useOrder.ts @@ -0,0 +1,142 @@ +/** + * useOrder(orderId) — polls the restaurant extension for status + * updates on a single order. + * + * Polls every `orderPollMs` (from app.config.modules.restaurant) + * while status is in a non-terminal state. Resets to an immediate + * fetch on `VisibilityService.onVisible` so a backgrounded tab + * catches up the moment it comes back. Cleans the interval on + * scope dispose. + * + * Status is treated as an open string (see KNOWN_ORDER_STATUSES) + * — production / kitchen workflow (aiolabs/restaurant#4) may + * introduce new states. + */ +import { onScopeDispose, ref, watch, type Ref } from 'vue' +import { + injectService, + tryInjectService, + SERVICE_TOKENS, +} from '@/core/di-container' +import appConfig from '@/app.config' +import type { RestaurantAPI } from '../services/RestaurantAPI' +import type { + Order, + OrderItemRow, + OrderStatus, +} from '../types/restaurant' + +const TERMINAL_STATUSES: OrderStatus[] = [ + 'completed', + 'canceled', + 'refunded', +] + +export interface UseOrderReturn { + order: Ref + items: Ref + isLoading: Ref + error: Ref + refresh: () => Promise +} + +export function useOrder(orderId: Ref | string): UseOrderReturn { + const api = injectService(SERVICE_TOKENS.RESTAURANT_API) + const visibility = tryInjectService<{ + onVisible: (cb: () => void) => () => void + onHidden: (cb: () => void) => () => void + }>(SERVICE_TOKENS.VISIBILITY_SERVICE) + + const pollMs = + ( + appConfig.modules.restaurant as + | { config?: { orderPollMs?: number } } + | undefined + )?.config?.orderPollMs ?? 5000 + + const order = ref(null) + const items = ref([]) + const isLoading = ref(false) + const error = ref(null) + + let timer: ReturnType | null = null + let unsubVisible: (() => void) | null = null + + function targetId(): string { + return typeof orderId === 'string' ? orderId : orderId.value + } + + async function fetchOnce(): Promise { + const id = targetId() + if (!id) return + isLoading.value = true + try { + const data = await api.getOrder(id) + order.value = data.order + items.value = data.items + error.value = null + } catch (err) { + error.value = err instanceof Error ? err : new Error(String(err)) + } finally { + isLoading.value = false + } + } + + function isTerminal(status: OrderStatus | undefined): boolean { + return !!status && TERMINAL_STATUSES.includes(status) + } + + function startPolling(): void { + if (timer) return + timer = setInterval(async () => { + if (isTerminal(order.value?.status)) { + stopPolling() + return + } + await fetchOnce() + }, pollMs) + } + + function stopPolling(): void { + if (timer) { + clearInterval(timer) + timer = null + } + } + + // Refetch immediately when the tab becomes visible again — useful + // for mobile where polling pauses during background. + if (visibility) { + unsubVisible = visibility.onVisible(() => { + fetchOnce().then(() => { + if (!isTerminal(order.value?.status)) startPolling() + }) + }) + } + + watch( + () => targetId(), + async (id) => { + stopPolling() + order.value = null + items.value = [] + if (!id) return + await fetchOnce() + if (!isTerminal((order.value as Order | null)?.status)) startPolling() + }, + { immediate: true } + ) + + onScopeDispose(() => { + stopPolling() + unsubVisible?.() + }) + + return { + order, + items, + isLoading, + error, + refresh: fetchOnce, + } +} diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index 1195dc0..ea1b181 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -94,6 +94,18 @@ export const restaurantModule: ModulePlugin = { component: () => import('./views/CartPage.vue'), meta: { requiresAuth: false, title: 'Cart' }, }, + { + path: '/checkout', + name: 'restaurant-checkout', + component: () => import('./views/CheckoutPage.vue'), + meta: { requiresAuth: false, title: 'Checkout' }, + }, + { + path: '/orders/:id', + name: 'restaurant-order', + component: () => import('./views/OrderStatusPage.vue'), + meta: { requiresAuth: false, title: 'Order' }, + }, ] as RouteRecordRaw[], } diff --git a/src/modules/restaurant/views/CheckoutPage.vue b/src/modules/restaurant/views/CheckoutPage.vue new file mode 100644 index 0000000..4e7dd3d --- /dev/null +++ b/src/modules/restaurant/views/CheckoutPage.vue @@ -0,0 +1,186 @@ + + + diff --git a/src/modules/restaurant/views/OrderStatusPage.vue b/src/modules/restaurant/views/OrderStatusPage.vue new file mode 100644 index 0000000..766e9ae --- /dev/null +++ b/src/modules/restaurant/views/OrderStatusPage.vue @@ -0,0 +1,257 @@ + + + From 30d7d1c3cbc794e00eba50b77a9d8db3564ce399 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:34:36 +0200 Subject: [PATCH 2/8] feat(restaurant): orders list + settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit views/OrdersListPage.vue — grouped by day, source of truth is STORAGE_SERVICE['restaurant.lastOrders.v1'] (newest first, cap 50). Each row deep-links to /orders/:id where the detail page re-fetches the live order over REST so stale status never displays here. views/SettingsPage.vue — customer-side preferences persisted to STORAGE_SERVICE['restaurant.settings.v1']: - currencyDisplay toggle ('sats' | 'msat'). Local display only, the extension is always msat-canonical. - relayOverride input (comma-separated). Reload required since RelayHub initializes once on boot. - 'Clear local data' destructive button — wipes cart, history, recent venues but does NOT refund/cancel placed orders. Routes added: /orders, /settings. Verified: vue-tsc -b clean against the whole webapp. --- src/modules/restaurant/index.ts | 12 ++ .../restaurant/views/OrdersListPage.vue | 116 +++++++++++++ src/modules/restaurant/views/SettingsPage.vue | 153 ++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 src/modules/restaurant/views/OrdersListPage.vue create mode 100644 src/modules/restaurant/views/SettingsPage.vue diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index ea1b181..643a61f 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -106,6 +106,18 @@ export const restaurantModule: ModulePlugin = { component: () => import('./views/OrderStatusPage.vue'), meta: { requiresAuth: false, title: 'Order' }, }, + { + path: '/orders', + name: 'restaurant-orders', + component: () => import('./views/OrdersListPage.vue'), + meta: { requiresAuth: false, title: 'Orders' }, + }, + { + path: '/settings', + name: 'restaurant-settings', + component: () => import('./views/SettingsPage.vue'), + meta: { requiresAuth: false, title: 'Settings' }, + }, ] as RouteRecordRaw[], } diff --git a/src/modules/restaurant/views/OrdersListPage.vue b/src/modules/restaurant/views/OrdersListPage.vue new file mode 100644 index 0000000..fabbc8c --- /dev/null +++ b/src/modules/restaurant/views/OrdersListPage.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/modules/restaurant/views/SettingsPage.vue b/src/modules/restaurant/views/SettingsPage.vue new file mode 100644 index 0000000..99b0d28 --- /dev/null +++ b/src/modules/restaurant/views/SettingsPage.vue @@ -0,0 +1,153 @@ + + + From 34de6434e9b1436a17cc0277713b998df252bd23 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:40:27 +0200 Subject: [PATCH 3/8] feat(restaurant): Nostr live overlay (NIP-99) for menu state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services/RestaurantNostrSync.ts — BaseService subclass declaring 'RelayHub' as a dependency, so this.relayHub is populated by the framework. Subscribes to: kinds: [30402, 5] authors: [restaurant.nostr_pubkey] '#l': ['restaurant:'] Each kind-30402 (NIP-99 classified listing) is parsed into a partial MenuItem patch keyed by the 'd' tag (the menu item id). NIP-33 replaceable semantics: incoming events older than what we have are dropped (defense against operator-side reordering bugs). Kind 5 (NIP-09 deletion request) populates a set; the useMenu computed filters those items out. The patch covers name, description, price, is_available, stock (derived from the NIP-99 'status' tag — 'sold' -> is_available: false + stock: 0; 'active' -> is_available: true). Items appearing for the first time via the relay (without a matching REST item) are intentionally ignored — federated foreign-menu indexing is a future concern (aiolabs/restaurant#8 / docs). useMenu — refactor: - rename internal baseItems = REST snapshot - items becomes a computed that overlays sync.overlay onto baseItems and filters sync.deleted out - tryInjectService is used so the composable still works in test environments without the sync service. views/RestaurantPage.vue — watches the resolved restaurant ref, opens sync.subscribe(restaurant.nostr_pubkey, restaurant.id) on arrival, tears it down on route leave / unmount. If relay hub isn't connected, subscribe is a no-op and REST continues to serve the menu (best-effort polish, not load-bearing). modules/restaurant/index.ts — install() now also constructs RestaurantNostrSync, registers it under SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC, and kicks off initialize with waitForDependencies + 3 retries. Init failure is logged as a warning and operation continues in no-overlay mode. Verified: vue-tsc -b clean; vite build clean against VITE_LNBITS_BASE_URL=http://localhost:5001 VITE_RESTAURANT_DEFAULT_SLUG=big-jays-bustaurant. End-to-end customer flow now matches the plan's verification section: / redirects to /r/big-jays-bustaurant /r/big-jays-bustaurant REST menu loads + Nostr sub opens tap item with mods ItemPage + ModifierSelector tap '+' on simple item quick-add to cart bottom-nav Cart /cart shows lines + total /checkout quote -> place -> pay bolt11(s) auto-redirect /orders/, polls every 5s /orders historical list /settings display + relay override + clear data --- src/modules/restaurant/composables/useMenu.ts | 33 ++- src/modules/restaurant/index.ts | 17 +- .../services/RestaurantNostrSync.ts | 211 ++++++++++++++++++ .../restaurant/views/RestaurantPage.vue | 34 ++- 4 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 src/modules/restaurant/services/RestaurantNostrSync.ts diff --git a/src/modules/restaurant/composables/useMenu.ts b/src/modules/restaurant/composables/useMenu.ts index b35e178..2c5d706 100644 --- a/src/modules/restaurant/composables/useMenu.ts +++ b/src/modules/restaurant/composables/useMenu.ts @@ -15,8 +15,13 @@ */ import { ref, computed, onScopeDispose, watch, type Ref } from 'vue' -import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import { + injectService, + tryInjectService, + SERVICE_TOKENS, +} from '@/core/di-container' import type { RestaurantAPI } from '../services/RestaurantAPI' +import type { RestaurantNostrSync } from '../services/RestaurantNostrSync' import type { EnrichedMenuItem, MenuNode, @@ -33,6 +38,7 @@ function looksLikeId(value: string): boolean { export interface UseMenuReturn { restaurant: Ref tree: Ref + /** Items with the Nostr live overlay merged in. */ items: Ref isLoading: Ref error: Ref @@ -41,10 +47,13 @@ export interface UseMenuReturn { export function useMenu(slugOrId: Ref | string): UseMenuReturn { const api = injectService(SERVICE_TOKENS.RESTAURANT_API) + const sync = tryInjectService( + SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC + ) const restaurant = ref(null) const tree = ref([]) - const items = ref([]) + const baseItems = ref([]) const isLoading = ref(false) const error = ref(null) @@ -54,6 +63,24 @@ export function useMenu(slugOrId: Ref | string): UseMenuReturn { typeof slugOrId === 'string' ? slugOrId : slugOrId.value ) + /** + * `items` exposes the base REST snapshot patched with the live + * overlay from RestaurantNostrSync. Operator edits on the + * extension side surface here within ~1s of arriving at the + * relay, without a refetch. + */ + const items = computed(() => { + const overlay = sync?.overlay + const deleted = sync?.deleted + if (!overlay && !deleted) return baseItems.value + return baseItems.value + .filter((it) => !(deleted && deleted.has(it.id))) + .map((it) => { + const patch = overlay?.get(it.id) + return patch ? ({ ...it, ...patch } as EnrichedMenuItem) : it + }) + }) + async function load(value: string): Promise { if (!value) return abortController?.abort() @@ -73,7 +100,7 @@ export function useMenu(slugOrId: Ref | string): UseMenuReturn { restaurant.value = menu.restaurant tree.value = menu.tree - items.value = menu.items + baseItems.value = menu.items } catch (err) { if (my.signal.aborted) return error.value = err instanceof Error ? err : new Error(String(err)) diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index 643a61f..3b57b9b 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -4,6 +4,7 @@ import type { ModulePlugin } from '@/core/types' import { container, SERVICE_TOKENS } from '@/core/di-container' import { RestaurantAPI } from './services/RestaurantAPI' +import { RestaurantNostrSync } from './services/RestaurantNostrSync' // v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug). // @@ -64,7 +65,21 @@ export const restaurantModule: ModulePlugin = { console.warn('🍴 RestaurantAPI init deferred:', error) }) - // RestaurantNostrSync lands in commit 8. + // Nostr live-overlay sync. Requires RelayHub from baseModule; + // BaseService.waitForDependencies handles the timing if base + // initialization hasn't quite landed by the time we get here. + const restaurantNostrSync = new RestaurantNostrSync() + container.provide( + SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC, + restaurantNostrSync + ) + await restaurantNostrSync + .initialize({ waitForDependencies: true, maxRetries: 3 }) + .catch((error) => { + // No-overlay mode is fine: REST still works, the menu just + // doesn't reflect operator edits without a page refresh. + console.warn('🍴 RestaurantNostrSync init deferred:', error) + }) console.log('✅ Restaurant module installed') }, diff --git a/src/modules/restaurant/services/RestaurantNostrSync.ts b/src/modules/restaurant/services/RestaurantNostrSync.ts new file mode 100644 index 0000000..c3e7166 --- /dev/null +++ b/src/modules/restaurant/services/RestaurantNostrSync.ts @@ -0,0 +1,211 @@ +/** + * RestaurantNostrSync — live overlay for menu item state via NIP-99. + * + * Subscribes to the restaurant's `nostr_pubkey` for: + * - kind 30402 (NIP-99 classified listings) tagged + * `["l", "restaurant:"]` + * - kind 5 (NIP-09 deletion requests) + * + * Each incoming 30402 is parsed into a partial MenuItem patch keyed + * by the `d` tag (= menu item id) and pushed to a reactive overlay + * map. `useMenu` merges this overlay into its `items` computed so + * price changes, sold-out flips, and availability updates render + * within ~1s of the operator's edit on the extension side. + * + * Subscription lifecycle is owned by the *consumer* (RestaurantPage + * opens on mount, closes on route leave). Visibility integration is + * handled implicitly by the RelayHub — backgrounded tabs lose the + * underlying WebSocket; we re-subscribe on the next mount. + * + * This service holds NO state about which restaurants are subscribed + * — it expects callers to track their own sub ids if they need to + * tear them down individually. + */ +import { reactive } from 'vue' +import type { Filter } from 'nostr-tools' +import { BaseService } from '@/core/base/BaseService' +import type { RelayHub } from '@/modules/base/nostr/relay-hub' +import type { MenuItem } from '../types/restaurant' + +interface NostrEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig?: string +} + +const SUB_KIND_LISTING = 30402 +const SUB_KIND_DELETION = 5 + +export type MenuItemPatch = Partial< + Pick< + MenuItem, + 'name' | 'description' | 'price' | 'is_available' | 'stock' | 'nostr_event_id' | 'nostr_event_created_at' + > +> + +export class RestaurantNostrSync extends BaseService { + protected readonly metadata = { + name: 'RestaurantNostrSync', + version: '1.0.0', + dependencies: ['RelayHub'] as string[], + } + + /** + * Reactive overlay: itemId → partial patch. `useMenu` watches + * this and merges into its `items` array. + */ + readonly overlay = reactive(new Map()) + + /** Deleted item ids — useMenu filters these out. */ + readonly deleted = reactive(new Set()) + + // BaseService auto-populates `this.relayHub` from + // SERVICE_TOKENS.RELAY_HUB because our `metadata.dependencies` + // includes 'RelayHub' — no manual inject needed in onInitialize. + + private unsubscribers = new Map void>() + + protected async onInitialize(): Promise { + this.debug('RestaurantNostrSync ready') + } + + /** Typed accessor for the BaseService-injected relay hub. */ + private get hub(): RelayHub | null { + return (this.relayHub as RelayHub | null) ?? null + } + + /** + * Open the per-restaurant subscription. Returns an unsubscribe + * fn for convenience; calling subscribe() again with the same + * restaurantId is a no-op (idempotent). + */ + subscribe(restaurantPubkey: string, restaurantId: string): () => void { + const hub = this.hub + if (!hub) { + this.debug('subscribe: relay hub not ready') + return () => {} + } + if (this.unsubscribers.has(restaurantId)) { + return () => this.unsubscribe(restaurantId) + } + const filter: Filter = { + kinds: [SUB_KIND_LISTING, SUB_KIND_DELETION], + authors: [restaurantPubkey], + '#l': [`restaurant:${restaurantId}`], + } + try { + const offEvent = hub.subscribe({ + id: `restaurant-${restaurantId}`, + filters: [filter], + onEvent: (event) => this.handleEvent(event as NostrEvent), + }) + this.unsubscribers.set(restaurantId, offEvent) + this.debug(`subscribed authors=${restaurantPubkey.slice(0, 8)}…`) + } catch (err) { + // RelayHub throws if not connected. The user can still browse + // via REST — this just means no live updates this session. + this.debug(`subscribe failed (live overlay disabled): ${String(err)}`) + } + return () => this.unsubscribe(restaurantId) + } + + unsubscribe(restaurantId: string): void { + const off = this.unsubscribers.get(restaurantId) + if (off) { + off() + this.unsubscribers.delete(restaurantId) + this.debug(`unsubscribed ${restaurantId}`) + } + } + + /** + * Drop all subscriptions and clear the overlay. BaseService + * defines this as async; we honor the signature even though our + * cleanup is synchronous. + */ + async dispose(): Promise { + for (const off of this.unsubscribers.values()) off() + this.unsubscribers.clear() + this.overlay.clear() + this.deleted.clear() + } + + // ----------------------------------------------------------------- // + // event handlers // + // ----------------------------------------------------------------- // + + private handleEvent(event: NostrEvent): void { + if (event.kind === SUB_KIND_LISTING) { + this.handleListing(event) + } else if (event.kind === SUB_KIND_DELETION) { + this.handleDeletion(event) + } + } + + private handleListing(event: NostrEvent): void { + const dTag = event.tags.find((t) => t[0] === 'd')?.[1] + if (!dTag) return + + // Skip if we've already seen a newer version of this addressable + // event (NIP-33 replaceable semantics — operator-side bug + // protection). + const existing = this.overlay.get(dTag) + if ( + existing?.nostr_event_created_at && + existing.nostr_event_created_at >= event.created_at + ) { + return + } + + const patch: MenuItemPatch = { + nostr_event_id: event.id, + nostr_event_created_at: event.created_at, + } + + const title = event.tags.find((t) => t[0] === 'title')?.[1] + if (title) patch.name = title + + const summary = event.tags.find((t) => t[0] === 'summary')?.[1] + if (summary) patch.description = summary + + const priceTag = event.tags.find((t) => t[0] === 'price') + if (priceTag && priceTag[1]) { + const parsed = parseFloat(priceTag[1]) + if (!Number.isNaN(parsed)) patch.price = parsed + } + + // NIP-99 status: 'active' | 'sold'. We map to is_available + + // stock=0 so the existing UI badges (sold out / low stock) + // render consistently. + const statusTag = event.tags.find((t) => t[0] === 'status')?.[1] + if (statusTag === 'sold') { + patch.is_available = false + patch.stock = 0 + } else if (statusTag === 'active') { + patch.is_available = true + } + + this.overlay.set(dTag, patch) + this.debug(`overlay merge id=${dTag.slice(0, 8)}…`) + } + + private handleDeletion(event: NostrEvent): void { + // NIP-09: an `a` tag references the addressable event the author + // is deleting. Format: 'kind:pubkey:dTag'. + for (const tag of event.tags) { + if (tag[0] !== 'a' || !tag[1]) continue + const parts = tag[1].split(':') + if (parts[0] !== String(SUB_KIND_LISTING)) continue + const dTag = parts[2] + if (dTag) { + this.deleted.add(dTag) + this.overlay.delete(dTag) + this.debug(`deletion id=${dTag.slice(0, 8)}…`) + } + } + } +} diff --git a/src/modules/restaurant/views/RestaurantPage.vue b/src/modules/restaurant/views/RestaurantPage.vue index abbdb50..3d97877 100644 --- a/src/modules/restaurant/views/RestaurantPage.vue +++ b/src/modules/restaurant/views/RestaurantPage.vue @@ -11,7 +11,7 @@ * handles modifier selection and "Add to cart" (cart store lands in * commit 5). */ -import { computed, toRef } from 'vue' +import { computed, onBeforeUnmount, toRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { Loader2, AlertCircle } from 'lucide-vue-next' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' @@ -21,16 +21,48 @@ import CategoryNav from '../components/CategoryNav.vue' import MenuTree from '../components/MenuTree.vue' import { useMenu } from '../composables/useMenu' import { useCartStore } from '../stores/cart' +import { + tryInjectService, + SERVICE_TOKENS, +} from '@/core/di-container' +import type { RestaurantNostrSync } from '../services/RestaurantNostrSync' const route = useRoute() const router = useRouter() const cart = useCartStore() +const sync = tryInjectService( + SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC +) const slug = computed(() => String(route.params.slug || '')) const { restaurant, tree, items, isLoading, error, refresh } = useMenu( toRef(() => slug.value) ) +// Open the Nostr live-overlay subscription the moment we know the +// restaurant's pubkey + id. Close it on route leave / unmount. If +// the relay hub isn't connected, sync.subscribe is a no-op (REST +// continues to work; the overlay is best-effort polish). +let activeRestaurantId: string | null = null +watch(restaurant, (r) => { + if (!sync) return + if (activeRestaurantId && activeRestaurantId !== r?.id) { + sync.unsubscribe(activeRestaurantId) + activeRestaurantId = null + } + if (r?.nostr_pubkey && r.id !== activeRestaurantId) { + sync.subscribe(r.nostr_pubkey, r.id) + activeRestaurantId = r.id + } +}) + +onBeforeUnmount(() => { + if (sync && activeRestaurantId) { + sync.unsubscribe(activeRestaurantId) + activeRestaurantId = null + } +}) + function openItem(itemId: string) { router.push(`/r/${slug.value}/item/${itemId}`) } From f2045c511d4b720b0cc7795dba2f4c13c8a1ea55 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:57:21 +0200 Subject: [PATCH 4/8] fix(restaurant): cart displays in menu currency; checkout previews live sat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cart was storing the menu item's price under a misnamed field `unit_msat` and labeling it 'sat' in the UI — so Big Jay's 25-GTQ Coffee showed as '25 sat' in the cart with no fiat conversion. The numbers were purely cosmetic (real money math goes through POST /orders/quote server-side) but misleading. stores/cart.ts: - rename CartLine.unit_msat → unit_price (since it isn't msat) - add CartLine.currency (snapshot from the menu item) - rename getter restaurantTotalsMsat → restaurantTotals; returns { amount, currency } per restaurant - rename grandTotalMsat → grandTotal; returns single { amount, currency } when the cart is one-currency, null when it spans multiple currencies (future festival aggregator with mixed-fiat restaurants — UI then falls back to per-restaurant subtotals) components/CartLineItem.vue: uses line.currency directly instead of a currencyHint prop and a hard-coded 'sat'. views/CartPage.vue: per-bucket and grand totals use the cart's currency. When the cart spans multiple currencies, hide the grand total and show a small explanatory caption. views/CheckoutPage.vue: - same display rename throughout - **new**: live ≈sat preview. On mount and whenever the cart changes, fires one POST /orders/quote per restaurant and surfaces `required_msat / 1000` as 'Pay in sats: ≈ X sat' so the customer sees the actual Lightning amount BEFORE clicking 'Pay & place order'. views/ItemPage.vue + RestaurantPage.vue: pass currency through to cart.addLine. Verified live against Big Jay's (GTQ-priced): - Coffee menu card: '25 GTQ' (unchanged) - Add to cart, /cart shows '25 GTQ' (was '25 sat') - /checkout subtotal: '25 GTQ', preview: '≈ 3,966 sat' - Quesadillas with 3 modifier groups: subtotal '80 GTQ', preview '≈ 12,691 sat' - 2x Tacos (Maíz + Brisket + Chicken): '170 GTQ', '≈ 26,968 sat' All sat amounts come from the extension's fiat-rate-aware /orders/quote endpoint (the companion fix on aiolabs/restaurant feat/restaurant-by-slug branch). Vue-tsc clean; vite build clean. --- .../restaurant/components/CartLineItem.vue | 13 ++- src/modules/restaurant/stores/cart.ts | 55 +++++++++--- src/modules/restaurant/views/CartPage.vue | 16 ++-- src/modules/restaurant/views/CheckoutPage.vue | 83 +++++++++++++++++-- src/modules/restaurant/views/ItemPage.vue | 3 +- .../restaurant/views/RestaurantPage.vue | 3 +- 6 files changed, 136 insertions(+), 37 deletions(-) diff --git a/src/modules/restaurant/components/CartLineItem.vue b/src/modules/restaurant/components/CartLineItem.vue index 0149867..cd27b60 100644 --- a/src/modules/restaurant/components/CartLineItem.vue +++ b/src/modules/restaurant/components/CartLineItem.vue @@ -10,7 +10,6 @@ import type { CartLine } from '../stores/cart' const props = defineProps<{ line: CartLine - currencyHint?: string }>() const emit = defineEmits<{ @@ -24,14 +23,12 @@ const modifierSummary = computed(() => ) const lineTotal = computed(() => { - // unit_msat is base + modifier delta snapshot; the cart store - // currently stores it as a sat-major number (price * 1000-less - // because the extension's `price` is already in the declared - // currency, not msat — see useCheckout's buildCreateOrder for the - // canonical conversion at order-place time). + // Displayed in the menu item's currency (e.g. GTQ). Authoritative + // sat conversion happens server-side at /orders/quote — the + // checkout page surfaces that as a "≈ X sat" badge. const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }) - const total = props.line.unit_msat * props.line.quantity - return `${fmt.format(total)} ${props.currencyHint || 'sat'}` + const total = props.line.unit_price * props.line.quantity + return `${fmt.format(total)} ${props.line.currency}` }) diff --git a/src/modules/restaurant/stores/cart.ts b/src/modules/restaurant/stores/cart.ts index 7beb1e4..dfc6839 100644 --- a/src/modules/restaurant/stores/cart.ts +++ b/src/modules/restaurant/stores/cart.ts @@ -30,8 +30,16 @@ export interface CartLine { /** Snapshot at add-time so the cart still renders if the menu * item is later edited / deleted. */ name: string - /** Base price + selected modifier deltas, in msat. */ - unit_msat: number + /** + * Base price + selected modifier deltas, **in the menu item's + * declared currency** (e.g. 25 for "25 GTQ" or 100 for "100 sat"). + * Authoritative sat conversion happens server-side via + * `POST /orders/quote`; the cart's value is for display only. + */ + unit_price: number + /** ISO-ish currency code from the menu item, e.g. "GTQ", "USD", + * "sat". Used for cart-side display labels. */ + currency: string quantity: number selected_modifiers: SelectedModifier[] note?: string | null @@ -91,19 +99,42 @@ export const useCartStore = defineStore('restaurant-cart', () => { return n }) - const restaurantTotalsMsat = computed>(() => { - const out: Record = {} + /** Per-restaurant subtotal in that restaurant's declared currency. */ + const restaurantTotals = computed< + Record + >(() => { + const out: Record = {} for (const rid of Object.keys(lines.value)) { - out[rid] = lines.value[rid].reduce( - (s, l) => s + l.unit_msat * l.quantity, - 0 - ) + const bucket = lines.value[rid] + if (!bucket.length) continue + out[rid] = { + amount: bucket.reduce( + (s, l) => s + l.unit_price * l.quantity, + 0 + ), + currency: bucket[0].currency, + } } return out }) - const grandTotalMsat = computed(() => - Object.values(restaurantTotalsMsat.value).reduce((s, v) => s + v, 0) + /** + * Single-currency cart subtotal. Returns null if the cart spans + * multiple currencies (e.g. one restaurant priced in GTQ + another + * in USD via the future festival aggregator) — the UI then falls + * back to per-restaurant subtotals only. + */ + const grandTotal = computed<{ amount: number; currency: string } | null>( + () => { + const totals = Object.values(restaurantTotals.value) + if (!totals.length) return null + const currencies = new Set(totals.map((t) => t.currency)) + if (currencies.size !== 1) return null + return { + amount: totals.reduce((s, t) => s + t.amount, 0), + currency: totals[0].currency, + } + } ) function linesFor(restaurantId: string): CartLine[] { @@ -214,8 +245,8 @@ export const useCartStore = defineStore('restaurant-cart', () => { // getters restaurantsInCart, itemCount, - restaurantTotalsMsat, - grandTotalMsat, + restaurantTotals, + grandTotal, linesFor, // actions setActiveRestaurant, diff --git a/src/modules/restaurant/views/CartPage.vue b/src/modules/restaurant/views/CartPage.vue index 7771c85..a485420 100644 --- a/src/modules/restaurant/views/CartPage.vue +++ b/src/modules/restaurant/views/CartPage.vue @@ -25,12 +25,12 @@ const buckets = computed(() => // a separate fetch for the cart page header. restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '', lines: cart.linesFor(rid), - totalMsat: cart.restaurantTotalsMsat[rid] ?? 0, + total: cart.restaurantTotals[rid] ?? null, })) ) -function fmtSat(value: number): string { - return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat` +function fmt(value: number, currency: string): string { + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}` } @@ -70,7 +70,7 @@ function fmtSat(value: number): string { - {{ fmtSat(b.totalMsat) }} + {{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }} @@ -91,12 +91,16 @@ function fmtSat(value: number): string {
-
+
Subtotal - {{ fmtSat(cart.grandTotalMsat) }} + {{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
+

+ Cart spans multiple currencies — per-restaurant subtotals above. + Lightning sat amount shown at checkout. +

+
+ + ≈ {{ new Intl.NumberFormat().format(previewSatPerRestaurant[b.restaurantId]!) }} sat +
@@ -141,10 +194,22 @@ async function placeOrder() {
-
+
Total - {{ fmtSat(cart.grandTotalMsat) }} + {{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }} + +
+
+ + + Pay in sats + + + ≈ {{ new Intl.NumberFormat().format(previewSatTotal) }} sat
diff --git a/src/modules/restaurant/views/ItemPage.vue b/src/modules/restaurant/views/ItemPage.vue index 945a4ca..2b971f7 100644 --- a/src/modules/restaurant/views/ItemPage.vue +++ b/src/modules/restaurant/views/ItemPage.vue @@ -87,7 +87,8 @@ function addToCart() { restaurant_slug: restaurant.value.slug, menu_item_id: item.value.id, name: item.value.name, - unit_msat: unitPrice.value, + unit_price: unitPrice.value, + currency: item.value.currency || restaurant.value.currency, quantity: quantity.value, selected_modifiers: selectedModifiers.value, note: note.value || null, diff --git a/src/modules/restaurant/views/RestaurantPage.vue b/src/modules/restaurant/views/RestaurantPage.vue index 3d97877..d9f06f0 100644 --- a/src/modules/restaurant/views/RestaurantPage.vue +++ b/src/modules/restaurant/views/RestaurantPage.vue @@ -84,7 +84,8 @@ function quickAdd(itemId: string) { restaurant_slug: restaurant.value.slug, menu_item_id: it.id, name: it.name, - unit_msat: it.price, + unit_price: it.price, + currency: it.currency || restaurant.value.currency, quantity: 1, selected_modifiers: [], note: null, From 10abfca555a1b5b70249a60360d0944baf555295 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 18:01:18 +0200 Subject: [PATCH 5/8] fix(checkout): pay via LNbits payments API directly (drop WalletService dep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useCheckout was injecting SERVICE_TOKENS.WALLET_SERVICE, but the restaurant-app bundle only registers base + restaurant modules — the wallet module isn't bundled because the restaurant surface is a customer ordering app, not a wallet UI. Result was a hard 'Service not found for token: Symbol(walletService)' on every checkout setup, which then cascaded into 'Cannot read properties of undefined (reading 'grandTotal')' downstream. Two options to fix: (a) bundle the whole wallet module, (b) talk to LNbits's payments endpoint directly. Going with (b) to match the market bundle's 'no wallet UI' pattern. Changes in useCheckout: - drop the WalletService import + injectService call - new local payBolt11(bolt11, adminkey) helper that POSTs to `${apiBaseUrl}/api/v1/payments` with X-Api-Key and { out: true, bolt11 } — same shape WalletService.sendPayment builds internally - balance precheck now reads AuthService.user.wallets[0].balance_msat (which LNbits's user object carries natively) instead of WalletService.balance. Comparison is msat-on-msat — no more sat→msat ×1000 dance - explicit 'No wallet available — please log in first.' error when the user object lacks wallets[0].adminkey - per-bolt11 failure now surfaces the real LNbits error text instead of WalletService's boolean swallow Verified vue-tsc clean. The 'Invalid vnode type' and 'undefined grandTotal' errors were both downstream of this setup failure; they go away when setup completes. --- .../restaurant/composables/useCheckout.ts | 88 ++++++++++++++----- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/src/modules/restaurant/composables/useCheckout.ts b/src/modules/restaurant/composables/useCheckout.ts index c6ea72b..d922c29 100644 --- a/src/modules/restaurant/composables/useCheckout.ts +++ b/src/modules/restaurant/composables/useCheckout.ts @@ -24,7 +24,7 @@ import { ref, type Ref } from 'vue' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { useAuth } from '@/composables/useAuthService' -import type WalletService from '@/modules/wallet/services/WalletService' +import appConfig from '@/app.config' import type { RestaurantAPI } from '../services/RestaurantAPI' import { useCartStore, type CartLine } from '../stores/cart' import type { @@ -90,10 +90,44 @@ function buildCreateOrder( export function useCheckout(): UseCheckoutReturn { const api = injectService(SERVICE_TOKENS.RESTAURANT_API) - const wallet = injectService(SERVICE_TOKENS.WALLET_SERVICE) const cart = useCartStore() const { user } = useAuth() + // We talk to LNbits's payments endpoint directly rather than + // pulling in the whole `wallet` module — the restaurant-app + // bundle is a customer surface, not a wallet UI, and `LnbitsAPI` + // is already registered by base. The customer's adminkey lives + // on AuthService.user.wallets[0].adminkey. + const apiBaseUrl = + ( + appConfig.modules.restaurant as + | { config?: { apiBaseUrl?: string } } + | undefined + )?.config?.apiBaseUrl || '' + + async function payBolt11(bolt11: string, adminkey: string): Promise { + const response = await fetch(`${apiBaseUrl}/api/v1/payments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': adminkey, + }, + body: JSON.stringify({ out: true, bolt11 }), + }) + if (!response.ok) { + let detail = response.statusText + try { + const body = await response.json() + if (body?.detail) detail = body.detail + } catch { + /* body wasn't JSON */ + } + throw new Error( + `Payment failed: ${response.status} ${detail}` + ) + } + } + const state = ref({ step: 'idle', progress: { current: 0, total: 0 }, @@ -141,27 +175,32 @@ export function useCheckout(): UseCheckoutReturn { quotes.push({ ...b, msat: quote.required_msat }) } - // 2. Pre-flight balance check. The webapp's WalletService - // exposes balance via PaymentService; we ask the service for - // the current cached value via its public computed. - // NOTE: balance values in this codebase are sats, not msat — - // convert before comparing. + // 2. Pre-flight balance check using AuthService's cached wallet + // balance (LNbits's user object carries balance_msat per wallet). const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0) - const balanceSat = ( - wallet as unknown as { balance?: { value?: number } } - ).balance?.value - if (typeof balanceSat === 'number') { - const balanceMsat = balanceSat * 1000 - if (balanceMsat < totalMsatRequired) { + const wallet0 = user.value?.wallets?.[0] + if (wallet0 && typeof wallet0.balance_msat === 'number') { + if (wallet0.balance_msat < totalMsatRequired) { + const needSat = Math.ceil(totalMsatRequired / 1000) + const haveSat = Math.floor(wallet0.balance_msat / 1000) state.value = { step: 'error', progress: { current: 0, total: buckets.length }, currentRestaurantSlug: null, - error: `Insufficient balance. Need ${Math.ceil(totalMsatRequired / 1000)} sat, have ${balanceSat} sat.`, + error: `Insufficient balance. Need ${needSat} sat, have ${haveSat} sat.`, } throw new Error(state.value.error!) } } + if (!wallet0?.adminkey) { + state.value = { + step: 'error', + progress: { current: 0, total: buckets.length }, + currentRestaurantSlug: null, + error: 'No wallet available — please log in first.', + } + throw new Error(state.value.error!) + } // 3. Place orders. state.value.step = 'placing' @@ -196,13 +235,20 @@ export function useCheckout(): UseCheckoutReturn { state.value.progress = { current: i, total: placed.length } if (!p.invoice) continue // cash orders skip payment - const success = await wallet.sendPayment({ - amount: Math.ceil(p.invoice.amount_msat / 1000), - destination: p.invoice.bolt11, - comment: `Order at ${p.restaurantSlug}`, - }) - if (success && p.invoice.payment_hash) { - paidHashes.push(p.invoice.payment_hash) + try { + await payBolt11(p.invoice.bolt11, wallet0.adminkey) + if (p.invoice.payment_hash) { + paidHashes.push(p.invoice.payment_hash) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + state.value = { + step: 'error', + progress: { current: i, total: placed.length }, + currentRestaurantSlug: p.restaurantSlug, + error: msg, + } + throw err } } From 705a94b475146d9d99f404116a5c3fb23e7e29f9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 18:06:40 +0200 Subject: [PATCH 6/8] feat(checkout): two-phase flow with QR + copy + external-wallet support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous all-in-one 'Pay & place order' button placed the orders AND immediately auto-paid from the LNbits wallet, so the bolt11 QR never rendered. Customers couldn't scan with their own phone wallet (Phoenix, Wallet of Satoshi, etc.) — they were stuck on the LNbits anon wallet by default. Split into two distinct phases: useCheckout (refactor): - state.step: 'idle' → 'quoting' → 'placing' → 'placed' → 'paying' → 'paid' (or 'error') - state.placedOrders: PlacedOrder[] survives across the two phases, exposing each restaurant's { order, invoice } - state.paidOrderIds: Set tracks which orders the customer auto-paid this session (external scans aren't in this set; the CheckoutPage poller tracks those) - placeOrders() — runs quote, balance precheck (warns only, doesn't block — the customer might pay externally), places orders, populates placedOrders - payOrder(idx) — pays one bolt11 via POST /api/v1/payments with the customer's wallets[0].adminkey - payAll() — convenience: payOrder for each unpaid placed order - reset() — clears state back to idle CheckoutPage (rewrite): Phase 1 (review): cart subtotal in menu currency + live ≈sat preview + 'Place order' CTA. Unchanged from before except the CTA no longer also pays. Phase 2 (pay): OrderInvoiceCard per placed order showing the QR, amount, copy button, and expiry countdown. 'Pay from my LNbits wallet' CTA wraps payAll(). The page also polls every 3s — when the extension's invoice listener flips an order to 'paid' (regardless of which wallet paid it — LNbits anon auto-pay OR external scan), the badge flips, the cart bucket for that restaurant clears, and once all placed orders are paid, we redirect to /orders/ after a 1.2s success splash. Errors from auto-pay don't kill the flow — the QR stays visible so the customer can fall back to an external wallet scan. This matches the typical restaurant UX: 'here's your bill, scan or auto-pay' rather than 'we charged your wallet without asking'. Verified: vue-tsc -b clean. --- .../restaurant/composables/useCheckout.ts | 282 ++++++----- src/modules/restaurant/views/CheckoutPage.vue | 466 +++++++++++++----- 2 files changed, 493 insertions(+), 255 deletions(-) diff --git a/src/modules/restaurant/composables/useCheckout.ts b/src/modules/restaurant/composables/useCheckout.ts index d922c29..f42e6c8 100644 --- a/src/modules/restaurant/composables/useCheckout.ts +++ b/src/modules/restaurant/composables/useCheckout.ts @@ -1,25 +1,31 @@ /** - * useCheckout — orchestrates the place-order + pay-bolt11 sequence - * for every restaurant currently in the cart. + * useCheckout — drives the customer's place-and-pay flow against + * the restaurant extension. * * v1 ships REST-only: - * for each restaurant in the cart: - * 1. quote (msat required) - * 2. balance pre-check (sum across all restaurants) - * 3. placeOrder → { order, invoice } - * 4. WalletService.sendPayment(bolt11) + * 1. quote (msat required) + * 2. balance pre-check (sum across all restaurants in the cart) + * 3. POST /orders → { order, invoice } + * 4. (optional, customer choice) POST /api/v1/payments to settle + * the bolt11 from the customer's LNbits wallet. They can also + * skip this step and scan the QR with any other wallet — the + * extension's invoice listener marks the order paid either way. * - * The festival aggregator (aiolabs/restaurant#8) exercises this - * same path with N > 1 restaurants in the cart. v1 happens to ship - * a UI where N == 1, but the orchestration is multi-restaurant - * already. + * Split into two distinct actions so the UI can render the QR codes + * between place and pay, giving the customer the option to scan + * with an external wallet rather than auto-paying from LNbits: * - * NIP-17 transport (aiolabs/restaurant#9) plugs in here later as a - * `transport: 'rest' | 'nostr'` option that gift-wraps the - * CreateOrder instead of POSTing it. The `buildCreateOrder` - * helper is the single point both transports build through, so - * adding loyalty (aiolabs/restaurant#5) is also a one-function - * change rather than touching every call site. + * placeOrders() — runs steps 1-3, populates `state.value.placedOrders` + * payOrder(idx) — runs step 4 for one placed-order index + * + * The festival aggregator (aiolabs/restaurant#8) exercises this same + * path with N > 1 restaurants in the cart. v1 happens to ship a UI + * where N == 1, but the orchestration is multi-restaurant ready. + * + * NIP-17 transport (aiolabs/restaurant#9) plugs in via the + * `buildCreateOrder` helper — single point both REST and Nostr + * transports construct CreateOrder. Loyalty (#5) injects its + * pass-through fields the same way. */ import { ref, type Ref } from 'vue' import { injectService, SERVICE_TOKENS } from '@/core/di-container' @@ -42,28 +48,46 @@ export interface PlacedOrder { invoice: OrderInvoice | null } -export interface CheckoutResult { - placedOrders: PlacedOrder[] - paidPaymentHashes: string[] -} - export interface CheckoutState { - step: 'idle' | 'quoting' | 'placing' | 'paying' | 'done' | 'error' + step: + | 'idle' + | 'quoting' + | 'placing' + | 'placed' + | 'paying' + | 'paid' + | 'error' progress: { current: number; total: number } currentRestaurantSlug: string | null + placedOrders: PlacedOrder[] + /** Set of `placedOrders[i].order.id` that have been auto-paid + * via LNbits in this session. External-wallet payments don't + * populate this — they're detected via the per-order poller in + * CheckoutPage. */ + paidOrderIds: Set error: string | null } export interface UseCheckoutReturn { state: Ref - checkout: () => Promise + /** Run quote → balance precheck → POST /orders for every cart + * bucket. Returns the placed orders (also persisted in state). */ + placeOrders: () => Promise + /** Pay one already-placed order's bolt11 from the customer's + * LNbits wallet. Idempotent — calling twice on the same order + * is a no-op after the first success. */ + payOrder: (placedIndex: number) => Promise + /** Pay every unpaid placed-order in sequence. Best-effort: if + * one fails, earlier successes stay paid. */ + payAll: () => Promise + /** Reset to idle and drop placed/paid state. */ + reset: () => void } /** * The single point CreateOrder is built — keeps loyalty (#5), NIP-17 * transport (#9), and tip overrides one-place changes rather than - * touching the whole flow. Today loyalty is unconfigured so the - * extra block stays at its defaults. + * touching the whole flow. */ function buildCreateOrder( restaurantId: string, @@ -76,7 +100,6 @@ function buildCreateOrder( selected_modifiers: l.selected_modifiers, note: l.note ?? undefined, })) - // Loyalty (#5) future-extension point: when implemented, inject // { loyalty_credits_msat, loyalty_pubkey } into extra.fields here. return { @@ -88,6 +111,17 @@ function buildCreateOrder( } } +function blankState(): CheckoutState { + return { + step: 'idle', + progress: { current: 0, total: 0 }, + currentRestaurantSlug: null, + placedOrders: [], + paidOrderIds: new Set(), + error: null, + } +} + export function useCheckout(): UseCheckoutReturn { const api = injectService(SERVICE_TOKENS.RESTAURANT_API) const cart = useCartStore() @@ -95,9 +129,7 @@ export function useCheckout(): UseCheckoutReturn { // We talk to LNbits's payments endpoint directly rather than // pulling in the whole `wallet` module — the restaurant-app - // bundle is a customer surface, not a wallet UI, and `LnbitsAPI` - // is already registered by base. The customer's adminkey lives - // on AuthService.user.wallets[0].adminkey. + // bundle is a customer surface, not a wallet UI. const apiBaseUrl = ( appConfig.modules.restaurant as @@ -105,52 +137,30 @@ export function useCheckout(): UseCheckoutReturn { | undefined )?.config?.apiBaseUrl || '' - async function payBolt11(bolt11: string, adminkey: string): Promise { - const response = await fetch(`${apiBaseUrl}/api/v1/payments`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Api-Key': adminkey, - }, - body: JSON.stringify({ out: true, bolt11 }), - }) - if (!response.ok) { - let detail = response.statusText - try { - const body = await response.json() - if (body?.detail) detail = body.detail - } catch { - /* body wasn't JSON */ - } - throw new Error( - `Payment failed: ${response.status} ${detail}` - ) - } + const state = ref(blankState()) + + function reset(): void { + state.value = blankState() } - const state = ref({ - step: 'idle', - progress: { current: 0, total: 0 }, - currentRestaurantSlug: null, - error: null, - }) + // ----------------------------------------------------------------- // + // place // + // ----------------------------------------------------------------- // - async function checkout(): Promise { + async function placeOrders(): Promise { const buckets = cart.restaurantsInCart.map((rid) => ({ restaurantId: rid, restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '', lines: cart.linesFor(rid), })) - if (!buckets.length) { throw new Error('Cart is empty') } state.value = { + ...blankState(), step: 'quoting', progress: { current: 0, total: buckets.length }, - currentRestaurantSlug: null, - error: null, } // 1. Quote per restaurant. @@ -175,32 +185,23 @@ export function useCheckout(): UseCheckoutReturn { quotes.push({ ...b, msat: quote.required_msat }) } - // 2. Pre-flight balance check using AuthService's cached wallet - // balance (LNbits's user object carries balance_msat per wallet). + // 2. Balance pre-check. const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0) const wallet0 = user.value?.wallets?.[0] if (wallet0 && typeof wallet0.balance_msat === 'number') { if (wallet0.balance_msat < totalMsatRequired) { const needSat = Math.ceil(totalMsatRequired / 1000) const haveSat = Math.floor(wallet0.balance_msat / 1000) - state.value = { - step: 'error', - progress: { current: 0, total: buckets.length }, - currentRestaurantSlug: null, - error: `Insufficient balance. Need ${needSat} sat, have ${haveSat} sat.`, - } - throw new Error(state.value.error!) + // Not fatal — the customer may still want to scan the QR + // and pay from an external wallet. Surface a warning but + // continue. + console.warn( + `[restaurant] LNbits wallet balance is below the cart total ` + + `(have ${haveSat} sat, need ${needSat} sat). Auto-pay will ` + + `fail; scan the QR with an external wallet to settle.` + ) } } - if (!wallet0?.adminkey) { - state.value = { - step: 'error', - progress: { current: 0, total: buckets.length }, - currentRestaurantSlug: null, - error: 'No wallet available — please log in first.', - } - throw new Error(state.value.error!) - } // 3. Place orders. state.value.step = 'placing' @@ -223,49 +224,86 @@ export function useCheckout(): UseCheckoutReturn { }) } - // 4. Pay each bolt11 sequentially. If a payment fails, the - // earlier successes are still placed-and-paid — best-effort - // is the v1 model (see plan; HODL atomicity is a future - // issue). - state.value.step = 'paying' - const paidHashes: string[] = [] - for (let i = 0; i < placed.length; i++) { - const p = placed[i] - state.value.currentRestaurantSlug = p.restaurantSlug - state.value.progress = { current: i, total: placed.length } - if (!p.invoice) continue // cash orders skip payment - - try { - await payBolt11(p.invoice.bolt11, wallet0.adminkey) - if (p.invoice.payment_hash) { - paidHashes.push(p.invoice.payment_hash) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - state.value = { - step: 'error', - progress: { current: i, total: placed.length }, - currentRestaurantSlug: p.restaurantSlug, - error: msg, - } - throw err - } - } - - // 5. Clear the paid lines from the cart. - for (const p of placed) { - cart.clearRestaurant(p.restaurantId) - } - - state.value = { - step: 'done', - progress: { current: placed.length, total: placed.length }, - currentRestaurantSlug: null, - error: null, - } - - return { placedOrders: placed, paidPaymentHashes: paidHashes } + state.value.step = 'placed' + state.value.placedOrders = placed + state.value.currentRestaurantSlug = null + return placed } - return { state, checkout } + // ----------------------------------------------------------------- // + // pay // + // ----------------------------------------------------------------- // + + async function payBolt11Raw( + bolt11: string, + adminkey: string + ): Promise { + const response = await fetch(`${apiBaseUrl}/api/v1/payments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': adminkey, + }, + body: JSON.stringify({ out: true, bolt11 }), + }) + if (!response.ok) { + let detail = response.statusText + try { + const body = await response.json() + if (body?.detail) detail = body.detail + } catch { + /* body wasn't JSON */ + } + throw new Error(`Payment failed: ${response.status} ${detail}`) + } + } + + async function payOrder(placedIndex: number): Promise { + const placed = state.value.placedOrders[placedIndex] + if (!placed) throw new Error(`No placed order at index ${placedIndex}`) + if (!placed.invoice) return // cash orders skip payment + if (state.value.paidOrderIds.has(placed.order.id)) return // already paid + + const adminkey = user.value?.wallets?.[0]?.adminkey + if (!adminkey) { + throw new Error('No wallet available — please log in first.') + } + + state.value.step = 'paying' + state.value.currentRestaurantSlug = placed.restaurantSlug + try { + await payBolt11Raw(placed.invoice.bolt11, adminkey) + // Set semantics keeps `paidOrderIds` from re-renders; rebuild + // it on update so Vue picks up the change. + state.value.paidOrderIds = new Set([ + ...state.value.paidOrderIds, + placed.order.id, + ]) + // Bump to 'paid' only when every placed order is paid. + if ( + state.value.placedOrders.every((p) => + state.value.paidOrderIds.has(p.order.id) + ) + ) { + state.value.step = 'paid' + } else { + state.value.step = 'placed' + } + } catch (err) { + state.value.step = 'error' + state.value.error = err instanceof Error ? err.message : String(err) + throw err + } + } + + async function payAll(): Promise { + for (let i = 0; i < state.value.placedOrders.length; i++) { + const p = state.value.placedOrders[i] + if (!p.invoice) continue + if (state.value.paidOrderIds.has(p.order.id)) continue + await payOrder(i) + } + } + + return { state, placeOrders, payOrder, payAll, reset } } diff --git a/src/modules/restaurant/views/CheckoutPage.vue b/src/modules/restaurant/views/CheckoutPage.vue index c5277c0..d9f47e5 100644 --- a/src/modules/restaurant/views/CheckoutPage.vue +++ b/src/modules/restaurant/views/CheckoutPage.vue @@ -1,15 +1,40 @@ From 1d815652c4052830936603242cd1038e499f3b01 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 18:09:15 +0200 Subject: [PATCH 7/8] fix(useOrder): use VisibilityService.registerService (not onVisible/onHidden) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VisibilityService exposes `registerService(name, onResume, onPause)` returning an unregister fn, not the `onVisible(cb)` / `onHidden(cb)` shape I'd invented. OrderStatusPage was throwing 'visibility.onVisible is not a function' on every mount. Rewire useOrder to register a (onResume, onPause) pair: onResume → immediate refetch + restart polling if status is non-terminal (useful for mobile where polling pauses during background) onPause → stop polling to save battery while hidden The returned unregister fn is called from onScopeDispose, same as before. Also fixed the related TS narrowing on order.value.status via the same Order|null cast already used elsewhere in this file. Verified vue-tsc -b clean. --- .../restaurant/composables/useOrder.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/modules/restaurant/composables/useOrder.ts b/src/modules/restaurant/composables/useOrder.ts index 423ffd2..00347da 100644 --- a/src/modules/restaurant/composables/useOrder.ts +++ b/src/modules/restaurant/composables/useOrder.ts @@ -19,6 +19,7 @@ import { SERVICE_TOKENS, } from '@/core/di-container' import appConfig from '@/app.config' +import type { VisibilityService } from '@/core/services/VisibilityService' import type { RestaurantAPI } from '../services/RestaurantAPI' import type { Order, @@ -42,10 +43,9 @@ export interface UseOrderReturn { export function useOrder(orderId: Ref | string): UseOrderReturn { const api = injectService(SERVICE_TOKENS.RESTAURANT_API) - const visibility = tryInjectService<{ - onVisible: (cb: () => void) => () => void - onHidden: (cb: () => void) => () => void - }>(SERVICE_TOKENS.VISIBILITY_SERVICE) + const visibility = tryInjectService( + SERVICE_TOKENS.VISIBILITY_SERVICE + ) const pollMs = ( @@ -60,7 +60,7 @@ export function useOrder(orderId: Ref | string): UseOrderReturn { const error = ref(null) let timer: ReturnType | null = null - let unsubVisible: (() => void) | null = null + let unregisterVisibility: (() => void) | null = null function targetId(): string { return typeof orderId === 'string' ? orderId : orderId.value @@ -105,13 +105,25 @@ export function useOrder(orderId: Ref | string): UseOrderReturn { } // Refetch immediately when the tab becomes visible again — useful - // for mobile where polling pauses during background. + // for mobile where polling pauses during background. The + // VisibilityService takes (name, onResume, onPause) and returns + // an unregister fn. if (visibility) { - unsubVisible = visibility.onVisible(() => { - fetchOnce().then(() => { - if (!isTerminal(order.value?.status)) startPolling() - }) - }) + unregisterVisibility = visibility.registerService( + `useOrder-${typeof orderId === 'string' ? orderId : 'ref'}`, + async () => { + await fetchOnce() + if ( + !isTerminal((order.value as Order | null)?.status) + ) { + startPolling() + } + }, + async () => { + // pause polling while hidden — saves battery on mobile + stopPolling() + } + ) } watch( @@ -129,7 +141,7 @@ export function useOrder(orderId: Ref | string): UseOrderReturn { onScopeDispose(() => { stopPolling() - unsubVisible?.() + unregisterVisibility?.() }) return { From 15545c9b5ef587212c086d5142a5b684911bf4e8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 18:15:26 +0200 Subject: [PATCH 8/8] feat(restaurant): customer-friendly order status labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order status came through to the customer as raw operational strings — 'paid', 'accepted', 'ready'. These are fine for the operator's KDS but unfriendly for the customer waiting on their food. types/restaurant.ts: + FRIENDLY_ORDER_STATUS map (status → label) pending → 'Awaiting payment' paid → 'Order received' accepted → 'Cooking' ready → 'Ready for pickup' completed → 'Served' canceled → 'Canceled' refunded → 'Refunded' + friendlyOrderStatus(status) helper. Unknown statuses (future kitchen-workflow values from aiolabs/restaurant#4 — e.g. 'preparing', 'plating', 'in_service') fall through to a titlecased version of the raw key so the build stays green and the surface stays readable. views/OrderStatusPage.vue: - Status Badge uses friendlyOrderStatus(). - Alert sections now have one per status with appropriate copy: paid → 'Order received / Payment confirmed — the kitchen will start preparing it shortly.' accepted → 'Cooking / Your food is being made.' ready → 'Ready for pickup / Pick up at the counter.' completed → 'Served / Enjoy! Thanks for ordering.' views/CheckoutPage.vue: Phase 2 status badge uses friendlyOrderStatus() so the checkout's live per-restaurant status pill matches the language on the order page. Deeper kitchen workflow (prep stations, courses, ETA, per-station status) stays on aiolabs/restaurant#4 — this commit is the cheap win that ships with the existing data model unchanged. --- src/modules/restaurant/types/restaurant.ts | 28 ++++++++++++++++ src/modules/restaurant/views/CheckoutPage.vue | 3 +- .../restaurant/views/OrderStatusPage.vue | 32 ++++++++++++++++--- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/modules/restaurant/types/restaurant.ts b/src/modules/restaurant/types/restaurant.ts index 87a61cf..d7044b8 100644 --- a/src/modules/restaurant/types/restaurant.ts +++ b/src/modules/restaurant/types/restaurant.ts @@ -233,6 +233,34 @@ export const KNOWN_ORDER_STATUSES = [ export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number] export type OrderStatus = string +/** + * Customer-facing labels for order statuses. The extension's raw + * status names are operational ('paid' / 'accepted' / 'ready') but + * customers prefer human-friendly framing ('Order received' / + * 'Cooking' / 'Ready for pickup'). + * + * Future statuses from aiolabs/restaurant#4 (kitchen workflow) — + * 'preparing', 'plating', 'at_pass', 'in_service', etc — can land + * here as they arrive. Unknown values fall through to the raw + * status string titlecased. + */ +export const FRIENDLY_ORDER_STATUS: Record = { + pending: 'Awaiting payment', + paid: 'Order received', + accepted: 'Cooking', + ready: 'Ready for pickup', + completed: 'Served', + canceled: 'Canceled', + refunded: 'Refunded', +} + +export function friendlyOrderStatus(status: OrderStatus): string { + if (status in FRIENDLY_ORDER_STATUS) return FRIENDLY_ORDER_STATUS[status] + // Unknown status — titlecase the raw key as a graceful fallback. + if (!status) return '' + return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ') +} + export interface Order { id: string restaurant_id: string diff --git a/src/modules/restaurant/views/CheckoutPage.vue b/src/modules/restaurant/views/CheckoutPage.vue index d9f47e5..096ee85 100644 --- a/src/modules/restaurant/views/CheckoutPage.vue +++ b/src/modules/restaurant/views/CheckoutPage.vue @@ -46,6 +46,7 @@ import { Separator } from '@/components/ui/separator' import OrderInvoiceCard from '../components/OrderInvoiceCard.vue' import { useCartStore } from '../stores/cart' import { useCheckout, type PlacedOrder } from '../composables/useCheckout' +import { friendlyOrderStatus } from '../types/restaurant' import { injectService, tryInjectService, @@ -383,7 +384,7 @@ function buildOrderInvoice(p: PlacedOrder) { :variant="isPaid(placed.order.id) ? 'default' : 'outline'" class="text-xs" > - {{ statusOf(placed.order.id) }} + {{ friendlyOrderStatus(statusOf(placed.order.id)) }}
{

- {{ order.status }} + {{ friendlyOrderStatus(order.status) }} @@ -161,13 +162,25 @@ const timeline = computed(() => { /> - Payment received + Order received - The kitchen is on it. + Payment confirmed — the kitchen will start preparing it + shortly. + + + + + + Cooking + + Your food is being made. @@ -176,12 +189,21 @@ const timeline = computed(() => { class="mb-4 border-amber-500/40" > - Ready + Ready for pickup Pick up at the counter. + + + Served + Enjoy! Thanks for ordering. + + Items