diff --git a/package.json b/package.json index d7db78c..89785e6 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,8 @@ "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: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", + "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", "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 deleted file mode 100644 index e910921..0000000 --- a/restaurant.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - Restaurant — Order - - - - -
- - - diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 15aa084..411ebad 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -173,10 +173,6 @@ 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 deleted file mode 100644 index cd27b60..0000000 --- a/src/modules/restaurant/components/CartLineItem.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - diff --git a/src/modules/restaurant/components/CategoryNav.vue b/src/modules/restaurant/components/CategoryNav.vue deleted file mode 100644 index 21b1dff..0000000 --- a/src/modules/restaurant/components/CategoryNav.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - - - diff --git a/src/modules/restaurant/components/MenuItemCard.vue b/src/modules/restaurant/components/MenuItemCard.vue deleted file mode 100644 index 4f00537..0000000 --- a/src/modules/restaurant/components/MenuItemCard.vue +++ /dev/null @@ -1,149 +0,0 @@ - - - diff --git a/src/modules/restaurant/components/MenuTree.vue b/src/modules/restaurant/components/MenuTree.vue deleted file mode 100644 index d53885a..0000000 --- a/src/modules/restaurant/components/MenuTree.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/src/modules/restaurant/components/ModifierSelector.vue b/src/modules/restaurant/components/ModifierSelector.vue deleted file mode 100644 index b556b3d..0000000 --- a/src/modules/restaurant/components/ModifierSelector.vue +++ /dev/null @@ -1,221 +0,0 @@ - - - diff --git a/src/modules/restaurant/components/OrderInvoiceCard.vue b/src/modules/restaurant/components/OrderInvoiceCard.vue deleted file mode 100644 index 64d613b..0000000 --- a/src/modules/restaurant/components/OrderInvoiceCard.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - diff --git a/src/modules/restaurant/components/RestaurantHeader.vue b/src/modules/restaurant/components/RestaurantHeader.vue deleted file mode 100644 index 8a4af4f..0000000 --- a/src/modules/restaurant/components/RestaurantHeader.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/src/modules/restaurant/composables/useCheckout.ts b/src/modules/restaurant/composables/useCheckout.ts deleted file mode 100644 index f42e6c8..0000000 --- a/src/modules/restaurant/composables/useCheckout.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * 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 deleted file mode 100644 index 2c5d706..0000000 --- a/src/modules/restaurant/composables/useMenu.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 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 deleted file mode 100644 index 00347da..0000000 --- a/src/modules/restaurant/composables/useOrder.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * 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 deleted file mode 100644 index 3b57b9b..0000000 --- a/src/modules/restaurant/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 788a342..0000000 --- a/src/modules/restaurant/services/RestaurantAPI.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * 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 deleted file mode 100644 index c3e7166..0000000 --- a/src/modules/restaurant/services/RestaurantNostrSync.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * 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 deleted file mode 100644 index dfc6839..0000000 --- a/src/modules/restaurant/stores/cart.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * 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 deleted file mode 100644 index d7044b8..0000000 --- a/src/modules/restaurant/types/restaurant.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * 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 deleted file mode 100644 index a485420..0000000 --- a/src/modules/restaurant/views/CartPage.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - diff --git a/src/modules/restaurant/views/CheckoutPage.vue b/src/modules/restaurant/views/CheckoutPage.vue deleted file mode 100644 index 096ee85..0000000 --- a/src/modules/restaurant/views/CheckoutPage.vue +++ /dev/null @@ -1,452 +0,0 @@ - - - diff --git a/src/modules/restaurant/views/HomePage.vue b/src/modules/restaurant/views/HomePage.vue deleted file mode 100644 index 5f71516..0000000 --- a/src/modules/restaurant/views/HomePage.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/src/modules/restaurant/views/ItemPage.vue b/src/modules/restaurant/views/ItemPage.vue deleted file mode 100644 index 2b971f7..0000000 --- a/src/modules/restaurant/views/ItemPage.vue +++ /dev/null @@ -1,246 +0,0 @@ - - -