From 7e95a558b4e2f5de58e4b966008da1f73f9c91dd Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:32:04 +0200 Subject: [PATCH] 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 @@ + + +