diff --git a/package.json b/package.json index 89785e6..d7db78c 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,11 @@ "dev:forum": "vite --host --config vite.forum.config.ts", "build:forum": "vue-tsc -b && vite build --config vite.forum.config.ts", "preview:forum": "vite preview --host --config vite.forum.config.ts", - "dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks -c blue,magenta,cyan,yellow,green,blue,red,gray \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\"", - "build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks", + "dev:restaurant": "vite --host --config vite.restaurant.config.ts", + "build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts", + "preview:restaurant": "vite preview --host --config vite.restaurant.config.ts", + "dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"", + "build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/restaurant.html b/restaurant.html new file mode 100644 index 0000000..e910921 --- /dev/null +++ b/restaurant.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + Restaurant — Order + + + + +
+ + + diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 411ebad..15aa084 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -173,6 +173,10 @@ export const SERVICE_TOKENS = { // Expenses services EXPENSES_API: Symbol('expensesAPI'), + + // Restaurant services + RESTAURANT_API: Symbol('restaurantAPI'), + RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'), } as const // Type-safe injection helpers diff --git a/src/modules/restaurant/components/CartLineItem.vue b/src/modules/restaurant/components/CartLineItem.vue new file mode 100644 index 0000000..cd27b60 --- /dev/null +++ b/src/modules/restaurant/components/CartLineItem.vue @@ -0,0 +1,94 @@ + + + diff --git a/src/modules/restaurant/components/CategoryNav.vue b/src/modules/restaurant/components/CategoryNav.vue new file mode 100644 index 0000000..21b1dff --- /dev/null +++ b/src/modules/restaurant/components/CategoryNav.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/modules/restaurant/components/MenuItemCard.vue b/src/modules/restaurant/components/MenuItemCard.vue new file mode 100644 index 0000000..4f00537 --- /dev/null +++ b/src/modules/restaurant/components/MenuItemCard.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/modules/restaurant/components/MenuTree.vue b/src/modules/restaurant/components/MenuTree.vue new file mode 100644 index 0000000..d53885a --- /dev/null +++ b/src/modules/restaurant/components/MenuTree.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/modules/restaurant/components/ModifierSelector.vue b/src/modules/restaurant/components/ModifierSelector.vue new file mode 100644 index 0000000..b556b3d --- /dev/null +++ b/src/modules/restaurant/components/ModifierSelector.vue @@ -0,0 +1,221 @@ + + + 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/components/RestaurantHeader.vue b/src/modules/restaurant/components/RestaurantHeader.vue new file mode 100644 index 0000000..8a4af4f --- /dev/null +++ b/src/modules/restaurant/components/RestaurantHeader.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/modules/restaurant/composables/useCheckout.ts b/src/modules/restaurant/composables/useCheckout.ts new file mode 100644 index 0000000..f42e6c8 --- /dev/null +++ b/src/modules/restaurant/composables/useCheckout.ts @@ -0,0 +1,309 @@ +/** + * useCheckout — drives the customer's place-and-pay flow against + * the restaurant extension. + * + * v1 ships REST-only: + * 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. + * + * 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: + * + * 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' +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 CheckoutState { + 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 + /** 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. + */ +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', + } +} + +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() + 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. + const apiBaseUrl = + ( + appConfig.modules.restaurant as + | { config?: { apiBaseUrl?: string } } + | undefined + )?.config?.apiBaseUrl || '' + + const state = ref(blankState()) + + function reset(): void { + state.value = blankState() + } + + // ----------------------------------------------------------------- // + // place // + // ----------------------------------------------------------------- // + + 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 }, + } + + // 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. 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) + // 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.` + ) + } + } + + // 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, + }) + } + + state.value.step = 'placed' + state.value.placedOrders = placed + state.value.currentRestaurantSlug = null + return placed + } + + // ----------------------------------------------------------------- // + // 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/composables/useMenu.ts b/src/modules/restaurant/composables/useMenu.ts new file mode 100644 index 0000000..2c5d706 --- /dev/null +++ b/src/modules/restaurant/composables/useMenu.ts @@ -0,0 +1,124 @@ +/** + * useMenu — fetches a restaurant's menu via REST. + * + * v1: REST-only via RestaurantAPI.getMenu(). The Nostr live-overlay + * merge lands in commit 8 (subscribes to kind-30402 listings for the + * restaurant's pubkey and patches `items` reactively). + * + * Usage: + * const { restaurant, tree, items, isLoading, error, refresh } + * = useMenu(slugOrId) + * + * Pass a slug or an id — the composable picks the right endpoint + * based on the format. Slugs are kebab-case strings; ids are + * urlsafe-short-hash from the extension (alphanumeric, ~22 chars). + */ + +import { ref, computed, onScopeDispose, watch, type Ref } from 'vue' +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, + Restaurant, +} from '../types/restaurant' + +// Heuristic: ids from urlsafe_short_hash are 22-char base64url. Slugs +// allow dashes and are typically shorter. Anything containing a dash +// or shorter than 20 chars is treated as a slug. +function looksLikeId(value: string): boolean { + return !value.includes('-') && value.length >= 20 && /^[A-Za-z0-9_-]+$/.test(value) +} + +export interface UseMenuReturn { + restaurant: Ref + tree: Ref + /** Items with the Nostr live overlay merged in. */ + items: Ref + isLoading: Ref + error: Ref + refresh: () => Promise +} + +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 baseItems = ref([]) + const isLoading = ref(false) + const error = ref(null) + + let abortController: AbortController | null = null + + const target = computed(() => + 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() + abortController = new AbortController() + const my = abortController + + isLoading.value = true + error.value = null + try { + const r = looksLikeId(value) + ? await api.getRestaurantById(value) + : await api.getRestaurantBySlug(value) + if (my.signal.aborted) return + + const menu = await api.getMenu(r.id) + if (my.signal.aborted) return + + restaurant.value = menu.restaurant + tree.value = menu.tree + baseItems.value = menu.items + } catch (err) { + if (my.signal.aborted) return + error.value = err instanceof Error ? err : new Error(String(err)) + } finally { + if (!my.signal.aborted) isLoading.value = false + } + } + + async function refresh(): Promise { + if (target.value) await load(target.value) + } + + // React to slug/id changes (Vue Router param updates). + watch(target, (value) => load(value), { immediate: true }) + + onScopeDispose(() => { + abortController?.abort() + }) + + return { restaurant, tree, items, isLoading, error, refresh } +} 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 new file mode 100644 index 0000000..3b57b9b --- /dev/null +++ b/src/modules/restaurant/index.ts @@ -0,0 +1,139 @@ +import type { App } from 'vue' +import type { RouteRecordRaw } from 'vue-router' +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). +// +// Feature-roadmap context (do NOT build in v1; see issues on +// aiolabs/restaurant): +// #1 PDF menu, #2 tier modes, #3 inventory, #4 kitchen workflow, +// #5 loyalty, #6 cost-of-goods, #7 deployment/monetization, +// #8 festival/aggregator (NIP-51), #9 NIP-17 order transport. +// +// Future-compatibility scaffolding baked in even at v1: +// • Cart store keys by restaurant_id (multi-restaurant ready +// for #8 without a refactor). +// • OrderStatus is an open string type (#4 may add states). +// • MenuItem.extra carries forward-compatible metadata for +// inventory (#3), cost-of-goods (#6), loyalty (#5), +// mode-gated badges (#2). +// • Module config has a `features: Record` +// slot reserved for tier gating (#2). +// • useCheckout builds CreateOrder through a single +// buildCreateOrder() helper so loyalty (#5) can inject +// loyalty fields without rewriting the flow. + +export interface RestaurantModuleConfig { + apiBaseUrl: string + defaultSlug: string + orderPollMs: number + currencyDisplay: 'sats' | 'msat' + features: Record +} + +/** + * Restaurant Module Plugin (v1 skeleton). + * + * The real surface — types/RestaurantAPI/views/cart/checkout/Nostr — + * lands across commits 3–8. This file is the lifecycle anchor and + * the route table. + */ +export const restaurantModule: ModulePlugin = { + name: 'restaurant', + version: '0.1.0', + dependencies: ['base'], + + async install(_app: App, options?: { config?: RestaurantModuleConfig }) { + console.log('🍴 Installing restaurant module…') + + if (!options?.config) { + throw new Error('Restaurant module requires configuration') + } + + // REST client. Initialized lazily — onInitialize() is a no-op + // (no async dependencies); failures here would only fire if + // the appConfig is malformed and we want to know about that. + const restaurantAPI = new RestaurantAPI() + container.provide(SERVICE_TOKENS.RESTAURANT_API, restaurantAPI) + await restaurantAPI + .initialize({ waitForDependencies: true, maxRetries: 1 }) + .catch((error) => { + console.warn('🍴 RestaurantAPI init deferred:', error) + }) + + // 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') + }, + + routes: [ + { + path: '/', + name: 'restaurant-home', + component: () => import('./views/HomePage.vue'), + meta: { requiresAuth: false, title: 'Restaurant' }, + }, + { + path: '/r/:slug', + name: 'restaurant-menu', + component: () => import('./views/RestaurantPage.vue'), + meta: { requiresAuth: false, title: 'Menu' }, + }, + { + path: '/r/:slug/item/:itemId', + name: 'restaurant-item', + component: () => import('./views/ItemPage.vue'), + meta: { requiresAuth: false, title: 'Item' }, + }, + { + path: '/cart', + name: 'restaurant-cart', + 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' }, + }, + { + 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[], +} + +export default restaurantModule diff --git a/src/modules/restaurant/services/RestaurantAPI.ts b/src/modules/restaurant/services/RestaurantAPI.ts new file mode 100644 index 0000000..788a342 --- /dev/null +++ b/src/modules/restaurant/services/RestaurantAPI.ts @@ -0,0 +1,170 @@ +/** + * Typed REST client for the LNbits "restaurant" extension. + * + * Mirrors the surface in ~/dev/shared/extensions/restaurant/views_api.py. + * Public read endpoints (`/restaurants/by-slug/{slug}`, + * `/restaurants/{id}/menu`, `/menu_items/{id}`) and customer order + * endpoints (`/orders/quote`, `/orders`, `/orders/{id}`) need no API + * key; `customer_pubkey` rides in the request body as optional + * metadata. + */ + +import { BaseService } from '@/core/base/BaseService' +import appConfig from '@/app.config' +import type { + CreateOrder, + CreateOrderItem, + MenuResponse, + MenuItem, + Order, + OrderInvoice, + OrderQuote, + OrderWithItems, + PlaceOrderResponse, + Restaurant, +} from '../types/restaurant' + +export class RestaurantAPI extends BaseService { + protected readonly metadata = { + name: 'RestaurantAPI', + version: '1.0.0', + dependencies: [] as string[], + } + + private baseUrl: string + + constructor() { + super() + const config = ( + appConfig.modules.restaurant as + | { config?: { apiBaseUrl?: string } } + | undefined + )?.config + if (!config?.apiBaseUrl) { + throw new Error( + 'RestaurantAPI: Missing apiBaseUrl in restaurant module config' + ) + } + this.baseUrl = config.apiBaseUrl + } + + protected async onInitialize(): Promise { + this.debug('RestaurantAPI initialized with base URL:', this.baseUrl) + } + + // ----------------------------------------------------------------- // + // request helper // + // ----------------------------------------------------------------- // + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}/restaurant/api/v1${endpoint}` + + const headers: Record = { + 'Content-Type': 'application/json', + } + if (options.headers) { + Object.assign(headers, options.headers as Record) + } + + const response = await fetch(url, { ...options, headers }) + + if (!response.ok) { + let detail = response.statusText + try { + const body = await response.json() + if (body?.detail) detail = body.detail + } catch { + // body wasn't JSON, fall through + } + throw new Error( + `RestaurantAPI ${options.method || 'GET'} ${endpoint} ` + + `failed: ${response.status} ${detail}` + ) + } + + if (response.status === 204) { + return undefined as T + } + return (await response.json()) as T + } + + // ----------------------------------------------------------------- // + // Restaurants // + // ----------------------------------------------------------------- // + + /** Resolve a URL slug → Restaurant payload. Used by /r/:slug. */ + async getRestaurantBySlug(slug: string): Promise { + return this.request( + `/restaurants/by-slug/${encodeURIComponent(slug)}` + ) + } + + async getRestaurantById(id: string): Promise { + return this.request( + `/restaurants/${encodeURIComponent(id)}` + ) + } + + /** + * Full hydrated menu — returns `{restaurant, tree, items}` where + * `tree` is the rooted MenuNode tree with children + items attached + * and `items` is the flat enriched list (modifier groups + options + * + availability windows pre-joined). + */ + async getMenu(restaurantId: string): Promise { + return this.request( + `/restaurants/${encodeURIComponent(restaurantId)}/menu` + ) + } + + async getMenuItem(itemId: string): Promise { + return this.request( + `/menu_items/${encodeURIComponent(itemId)}` + ) + } + + // ----------------------------------------------------------------- // + // Orders // + // ----------------------------------------------------------------- // + + /** + * Pre-flight balance check. Returns the msat the customer needs to + * cover this cart at one restaurant. Called per-restaurant by the + * webapp before opening any invoice — so a customer with + * insufficient funds gets one clean error rather than partially + * paid carts. + */ + async quoteOrder(items: CreateOrderItem[]): Promise { + return this.request('/orders/quote', { + method: 'POST', + body: JSON.stringify(items), + }) + } + + /** + * Place an order against one restaurant. Returns + * { order, invoice } + * where `invoice` is null for cash orders and the bolt11 payload + * otherwise. Pay the bolt11 with WalletService.sendPayment to + * settle. + */ + async placeOrder(payload: CreateOrder): Promise { + return this.request('/orders', { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + async getOrder(orderId: string): Promise { + return this.request( + `/orders/${encodeURIComponent(orderId)}` + ) + } +} + +// Re-export Order type for consumers reaching this surface for status +// strings — keeps the import chain shallow in views/composables. +export type { Order, OrderInvoice } 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/stores/cart.ts b/src/modules/restaurant/stores/cart.ts new file mode 100644 index 0000000..dfc6839 --- /dev/null +++ b/src/modules/restaurant/stores/cart.ts @@ -0,0 +1,261 @@ +/** + * Restaurant cart store (Pinia). + * + * Multi-restaurant ready by design — `lines` is keyed by + * `restaurant_id`. v1 only exercises the single-restaurant path + * (URL-driven /r/:slug), but the festival aggregator + * (aiolabs/restaurant#8) lands on top of this same store with + * **zero schema changes** — it just adds lines from more restaurants. + * + * Persistence: state is mirrored to STORAGE_SERVICE under + * `restaurant.cart.v1` (debounced). Re-hydrated on store creation. + * Money is integer **msat** end-to-end (matches the extension). + */ + +import { computed, watch } from 'vue' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' +import type { StorageService } from '@/core/services/StorageService' +import type { SelectedModifier } from '../types/restaurant' + +const STORAGE_KEY = 'restaurant.cart.v1' + +export interface CartLine { + /** UUID for dedup of identical items + modifier sets. */ + line_id: string + restaurant_id: string + restaurant_slug: string + menu_item_id: string + /** 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 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 +} + +export interface CartState { + lines: Record + activeRestaurantId: string | null +} + +const blank: CartState = { lines: {}, activeRestaurantId: null } + +function uuid(): string { + // crypto.randomUUID is available in modern browsers + Node 19+. + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + return `line-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +/** Two lines are merge-equivalent iff same item + same modifier set + * + same note. Modifier id sort gives an order-independent compare. */ +function modifierKey(mods: SelectedModifier[]): string { + return mods + .map((m) => m.modifier_id ?? m.name) + .filter(Boolean) + .sort() + .join('|') +} + +export const useCartStore = defineStore('restaurant-cart', () => { + const storage = tryInjectService( + SERVICE_TOKENS.STORAGE_SERVICE + ) + + // Hydrate from persistence (if available). + const initial = storage?.getUserData(STORAGE_KEY, blank) ?? blank + + const lines = ref>( + structuredClone(initial.lines || {}) + ) + const activeRestaurantId = ref( + initial.activeRestaurantId ?? null + ) + + // -------------------------- getters -------------------------------- + + const restaurantsInCart = computed(() => + Object.keys(lines.value).filter((rid) => lines.value[rid].length > 0) + ) + + const itemCount = computed(() => { + let n = 0 + for (const rid of Object.keys(lines.value)) { + for (const l of lines.value[rid]) n += l.quantity + } + return n + }) + + /** Per-restaurant subtotal in that restaurant's declared currency. */ + const restaurantTotals = computed< + Record + >(() => { + const out: Record = {} + for (const rid of Object.keys(lines.value)) { + 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 + }) + + /** + * 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[] { + return lines.value[restaurantId] || [] + } + + // -------------------------- actions -------------------------------- + + function setActiveRestaurant(restaurantId: string | null): void { + activeRestaurantId.value = restaurantId + } + + function addLine(line: Omit): CartLine { + const bucket = (lines.value[line.restaurant_id] ||= []) + const myKey = modifierKey(line.selected_modifiers) + const existing = bucket.find( + (l) => + l.menu_item_id === line.menu_item_id && + modifierKey(l.selected_modifiers) === myKey && + (l.note ?? '') === (line.note ?? '') + ) + if (existing) { + existing.quantity += line.quantity + return existing + } + const newLine: CartLine = { line_id: uuid(), ...line } + bucket.push(newLine) + if (!activeRestaurantId.value) { + activeRestaurantId.value = line.restaurant_id + } + return newLine + } + + function setQty(restaurantId: string, lineId: string, qty: number): void { + const bucket = lines.value[restaurantId] + if (!bucket) return + const line = bucket.find((l) => l.line_id === lineId) + if (!line) return + if (qty <= 0) { + removeLine(restaurantId, lineId) + return + } + line.quantity = qty + } + + function incrementQty(restaurantId: string, lineId: string): void { + const bucket = lines.value[restaurantId] + if (!bucket) return + const line = bucket.find((l) => l.line_id === lineId) + if (line) line.quantity++ + } + + function decrementQty(restaurantId: string, lineId: string): void { + const bucket = lines.value[restaurantId] + if (!bucket) return + const line = bucket.find((l) => l.line_id === lineId) + if (!line) return + if (line.quantity <= 1) { + removeLine(restaurantId, lineId) + } else { + line.quantity-- + } + } + + function removeLine(restaurantId: string, lineId: string): void { + const bucket = lines.value[restaurantId] + if (!bucket) return + lines.value[restaurantId] = bucket.filter((l) => l.line_id !== lineId) + if (lines.value[restaurantId].length === 0) { + delete lines.value[restaurantId] + if (activeRestaurantId.value === restaurantId) { + activeRestaurantId.value = restaurantsInCart.value[0] ?? null + } + } + } + + function clearRestaurant(restaurantId: string): void { + delete lines.value[restaurantId] + if (activeRestaurantId.value === restaurantId) { + activeRestaurantId.value = restaurantsInCart.value[0] ?? null + } + } + + function clear(): void { + lines.value = {} + activeRestaurantId.value = null + } + + // -------------------------- persistence ---------------------------- + + let writeTimer: ReturnType | null = null + function persist(): void { + if (!storage) return + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + storage.setUserData(STORAGE_KEY, { + lines: lines.value, + activeRestaurantId: activeRestaurantId.value, + }) + }, 200) + } + watch([lines, activeRestaurantId], persist, { deep: true }) + + return { + // state + lines, + activeRestaurantId, + // getters + restaurantsInCart, + itemCount, + restaurantTotals, + grandTotal, + linesFor, + // actions + setActiveRestaurant, + addLine, + setQty, + incrementQty, + decrementQty, + removeLine, + clearRestaurant, + clear, + } +}) diff --git a/src/modules/restaurant/types/restaurant.ts b/src/modules/restaurant/types/restaurant.ts new file mode 100644 index 0000000..d7044b8 --- /dev/null +++ b/src/modules/restaurant/types/restaurant.ts @@ -0,0 +1,336 @@ +/** + * TypeScript types mirroring the Python pydantic models in + * ~/dev/shared/extensions/restaurant/models.py. + * + * Hand-translated (no OpenAPI codegen on day one). Money on orders + * and order items is integer **msat** end-to-end, matching the + * extension. Display conversion is cosmetic via formatPrice(). + * + * Future-compatibility scaffolding lives here intentionally — see + * `OrderStatus`, `MenuItem.extra`, `Restaurant.mode`. Do not tighten + * those unless you've shipped the corresponding feature on the + * extension side first. + */ + +// --------------------------------------------------------------------- // +// Restaurant // +// --------------------------------------------------------------------- // + +export interface OpenHours { + // Weekday key '0'..'6' (Mon..Sun) → array of {start,end} 'HH:MM' ranges. + schedule: Record> +} + +export interface SocialLinks { + website?: string | null + instagram?: string | null + facebook?: string | null + twitter?: string | null + nostr?: string | null +} + +export interface RestaurantExtra { + notes?: string | null + // Plain dict — forward-compatible pass-through. See models.py. + fields: Record +} + +export interface Restaurant { + id: string + wallet: string + name: string + slug: string + description?: string | null + currency: string + timezone: string + location?: string | null + geohash?: string | null + logo_url?: string | null + banner_url?: string | null + social_links: SocialLinks + open_hours: OpenHours + is_open: boolean + accepts_cash: boolean + accepts_lightning: boolean + tip_presets: number[] + tax_rate: number + printer_endpoint?: string | null + nostr_pubkey?: string | null + nostr_relays: string[] + nostr_event_id?: string | null + nostr_event_created_at?: number | null + extra: RestaurantExtra + time: string // ISO 8601 from extension + // Set by the operator (aiolabs/restaurant#2: bar/bistro/full + // tiered modes). v1 webapp does not branch on it; future work + // may hide / show UI surfaces based on the venue's tier. + mode?: string +} + +// --------------------------------------------------------------------- // +// Menu tree // +// --------------------------------------------------------------------- // + +export interface MenuNodeRow { + id: string + restaurant_id: string + parent_id: string | null + name: string + description: string | null + sort_order: number + image_url: string | null + depth: number + path: string + time: string +} + +export interface MenuNode extends MenuNodeRow { + // Hydrated only by the /menu endpoint; never persisted. + children: MenuNode[] + items: MenuItem[] +} + +// --------------------------------------------------------------------- // +// Menu items // +// --------------------------------------------------------------------- // + +export interface MenuItemExtra { + notes?: string | null + // Pass-through for forward-compatible metadata: inventory + // (aiolabs/restaurant#3), happy-hour / cost-of-goods (#6), + // loyalty (#5), mode-gated badges (#2). v1 reads but never writes. + fields: Record +} + +export interface AvailabilityWindow { + id: string + menu_item_id: string + weekday: number | null // 0=Mon, 6=Sun, null = every day + start_time: string // 'HH:MM' + end_time: string // 'HH:MM' + time: string +} + +export interface ModifierGroup { + id: string + menu_item_id: string + name: string + // 'required' | 'optional' — see services.place_order for semantics. + // Kept open so future tier features can extend (#2). + kind: string + // 'one' | 'many' (radio / multi-select). + selection: string + min_selections: number + max_selections: number | null + sort_order: number + time: string +} + +export interface Modifier { + id: string + group_id: string + name: string + description: string | null + price_delta: number + is_default: boolean + sort_order: number + time: string +} + +export interface MenuItem { + id: string + restaurant_id: string + node_id: string | null + name: string + description: string | null + price: number + currency: string + sku: string | null + images: string[] + dietary: string[] + allergens: string[] + ingredients: string[] + calories: number | null + sort_order: number + is_available: boolean + is_featured: boolean + stock: number | null + low_stock_threshold: number | null + nostr_event_id: string | null + nostr_event_created_at: number | null + extra: MenuItemExtra + time: string +} + +/** Item with modifier groups + availability windows hydrated. + * Returned in the `items` array of `GET /restaurants/{id}/menu`. */ +export interface EnrichedMenuItem extends MenuItem { + modifier_groups: Array + availability_windows: AvailabilityWindow[] +} + +// --------------------------------------------------------------------- // +// Orders // +// --------------------------------------------------------------------- // + +export interface SelectedModifier { + group_id?: string | null + group_name?: string | null + modifier_id?: string | null + name: string + price_delta: number +} + +export interface CreateOrderItem { + menu_item_id: string + quantity: number + selected_modifiers: SelectedModifier[] + note?: string | null +} + +export interface OrderExtra { + fiat: boolean + fiat_currency?: string | null + fiat_rate?: number | null + refund_address?: string | null + // Pass-through, forward-compatible. Loyalty (#5) can ride here: + // e.g. { loyalty_credits_msat, loyalty_pubkey }. + fields: Record +} + +export interface CreateOrder { + restaurant_id: string + customer_pubkey?: string | null + customer_name?: string | null + customer_contact?: string | null + items: CreateOrderItem[] + tip_msat?: number + note?: string | null + parent_order_ref?: string | null + channel?: 'rest' | 'nostr' | 'kiosk' | 'pos' + payment_method?: 'lightning' | 'cash' | 'internal' + extra?: OrderExtra +} + +/** + * Known order statuses are listed here for UI hint mapping (icons, + * colors) — but the type is intentionally **open** so the production + * / kitchen workflow (aiolabs/restaurant#4) can introduce new states + * without breaking the build. Code that branches on status should + * use `KNOWN_ORDER_STATUSES.includes(...)` as a guard before + * assuming the styling lookup will resolve. + */ +export const KNOWN_ORDER_STATUSES = [ + 'pending', + 'paid', + 'accepted', + 'ready', + 'completed', + 'canceled', + 'refunded', +] as const + +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 + wallet: string + customer_pubkey?: string | null + customer_name?: string | null + customer_contact?: string | null + status: OrderStatus + channel: string + payment_method: string + payment_hash?: string | null + bolt11?: string | null + subtotal_msat: number + tip_msat: number + tax_msat: number + total_msat: number + currency_display: string + fiat_amount?: number | null + fiat_rate?: number | null + note?: string | null + parent_order_ref?: string | null + paid_at?: string | null + accepted_at?: string | null + ready_at?: string | null + completed_at?: string | null + canceled_at?: string | null + extra: OrderExtra + time: string +} + +export interface OrderItemRow { + id: string + order_id: string + menu_item_id: string | null + name: string + quantity: number + unit_price_msat: number + line_total_msat: number + selected_modifiers: SelectedModifier[] + note: string | null + time: string +} + +export interface OrderWithItems { + order: Order + items: OrderItemRow[] +} + +export interface OrderInvoice { + order_id: string + payment_hash: string + bolt11: string + amount_msat: number + expires_at: number +} + +/** Response of `POST /api/v1/orders`. */ +export interface PlaceOrderResponse { + order: Order + invoice: OrderInvoice | null +} + +/** Response of `GET /api/v1/restaurants/{id}/menu`. */ +export interface MenuResponse { + restaurant: Restaurant + tree: MenuNode[] + items: EnrichedMenuItem[] +} + +/** Response of `POST /api/v1/orders/quote`. */ +export interface OrderQuote { + required_msat: number +} diff --git a/src/modules/restaurant/views/CartPage.vue b/src/modules/restaurant/views/CartPage.vue new file mode 100644 index 0000000..a485420 --- /dev/null +++ b/src/modules/restaurant/views/CartPage.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/modules/restaurant/views/CheckoutPage.vue b/src/modules/restaurant/views/CheckoutPage.vue new file mode 100644 index 0000000..096ee85 --- /dev/null +++ b/src/modules/restaurant/views/CheckoutPage.vue @@ -0,0 +1,452 @@ + + + diff --git a/src/modules/restaurant/views/HomePage.vue b/src/modules/restaurant/views/HomePage.vue new file mode 100644 index 0000000..5f71516 --- /dev/null +++ b/src/modules/restaurant/views/HomePage.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/modules/restaurant/views/ItemPage.vue b/src/modules/restaurant/views/ItemPage.vue new file mode 100644 index 0000000..2b971f7 --- /dev/null +++ b/src/modules/restaurant/views/ItemPage.vue @@ -0,0 +1,246 @@ + + +