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 @@
+
+
+
+
+
+
+
+ {{ line.name }}
+
+
+ {{ lineTotal }}
+
+
+
+ {{ modifierSummary }}
+
+
+ Note: {{ line.note }}
+
+
+
+
+
+ {{ line.quantity }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ grp.name }}
+
+
+ Required
+ Optional
+ · pick one
+
+ · up to {{ grp.max_selections }}
+
+ · pick any
+
+
+
+ v && toggleOne(grp.id, String(v))"
+ class="space-y-1.5"
+ >
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
Lightning invoice
+
+ expires in {{ fmtCountdown(expiresInSec) }}
+ expired
+
+
+
+
![bolt11 invoice QR code]()
+
+
+ Amount
+
+ {{ Math.ceil(invoice.amount_msat / 1000) }} sat
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+ {{ restaurant.name }}
+
+
+ {{ restaurant.description }}
+
+
+ {{ openBadge.label }}
+
+ {{ restaurant.currency || 'sat' }}
+
+
+
+ {{ restaurant.location }}
+
+
+
+
+
+
+
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