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..d922c29 --- /dev/null +++ b/src/modules/restaurant/composables/useCheckout.ts @@ -0,0 +1,271 @@ +/** + * 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 appConfig from '@/app.config' +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 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 }, + 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 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 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!) + } + } + 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' + 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 + + 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 } + } + + 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..00347da --- /dev/null +++ b/src/modules/restaurant/composables/useOrder.ts @@ -0,0 +1,154 @@ +/** + * 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 { VisibilityService } from '@/core/services/VisibilityService' +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( + 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 unregisterVisibility: (() => 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. The + // VisibilityService takes (name, onResume, onPause) and returns + // an unregister fn. + if (visibility) { + 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( + () => 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() + unregisterVisibility?.() + }) + + 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 @@ + + +