From 41fbad3d905548712b0824e259c6263124e36c9a Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 09:42:21 +0200 Subject: [PATCH 1/9] feat(webapp): restaurant bundle skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone customer-facing bundle for the LNbits 'restaurant' extension, modeled on the market bundle. v1 ships single-venue (URL-driven via /r/:slug) with REST-only order placement; festival aggregator and NIP-17 transport are tracked as aiolabs/restaurant#8 and #9 respectively. Skeleton this commit lands: vite.restaurant.config.ts — port 5186, dist-restaurant/, green theme color, PWA manifest, alias @/app.config -> restaurant-app/. restaurant.html — entry; title 'Restaurant — Order'. src/restaurant-app/ main.ts — startApp + PWA SW registration. app.ts — module registration glue (baseModule + restaurantModule). app.config.ts — modules.restaurant config block. Reserves a features:{} slot for tier-gated UI (aiolabs/restaurant#2). App.vue — AppShell with Browse / Cart / Orders bottom-nav tabs. src/modules/restaurant/ index.ts — ModulePlugin shell with the future- roadmap context inlined as top-of-file comment (#1..#9). views/HomePage.vue — placeholder; commit 4 replaces it with real discovery + redirect. src/core/di-container.ts — RESTAURANT_API + RESTAURANT_NOSTR_SYNC tokens reserved (consumers land in 3 / 8). package.json — dev:restaurant, build:restaurant, preview:restaurant scripts and append to dev:all + build:demo. Verified: - vue-tsc -b passes (whole webapp, all bundles). - vite build --config vite.restaurant.config.ts builds clean against VITE_LNBITS_BASE_URL=http://localhost:5001 VITE_RESTAURANT_DEFAULT_SLUG=big-jays-bustaurant. - vite dev server boots on :5186 and serves the entry. Companion branch: extension repo aiolabs/restaurant on branch feat/restaurant-by-slug already provides GET /restaurants/by-slug/{slug} that the webapp will consume in commit 3. --- package.json | 7 +- restaurant.html | 20 ++++ src/core/di-container.ts | 4 + src/modules/restaurant/index.ts | 70 ++++++++++++ src/modules/restaurant/views/HomePage.vue | 32 ++++++ src/restaurant-app/App.vue | 45 ++++++++ src/restaurant-app/app.config.ts | 73 ++++++++++++ src/restaurant-app/app.ts | 125 ++++++++++++++++++++ src/restaurant-app/main.ts | 20 ++++ vite.restaurant.config.ts | 132 ++++++++++++++++++++++ 10 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 restaurant.html create mode 100644 src/modules/restaurant/index.ts create mode 100644 src/modules/restaurant/views/HomePage.vue create mode 100644 src/restaurant-app/App.vue create mode 100644 src/restaurant-app/app.config.ts create mode 100644 src/restaurant-app/app.ts create mode 100644 src/restaurant-app/main.ts create mode 100644 vite.restaurant.config.ts 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/index.ts b/src/modules/restaurant/index.ts new file mode 100644 index 0000000..765231f --- /dev/null +++ b/src/modules/restaurant/index.ts @@ -0,0 +1,70 @@ +import type { App } from 'vue' +import type { RouteRecordRaw } from 'vue-router' +import type { ModulePlugin } from '@/core/types' + +// 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') + } + + // Services (RestaurantAPI, RestaurantNostrSync) are wired in + // commits 3 and 8 respectively. v1 skeleton only registers + // the route table. + + console.log('✅ Restaurant module installed') + }, + + routes: [ + { + path: '/', + name: 'restaurant-home', + component: () => import('./views/HomePage.vue'), + meta: { requiresAuth: false, title: 'Restaurant' }, + }, + ] as RouteRecordRaw[], +} + +export default restaurantModule diff --git a/src/modules/restaurant/views/HomePage.vue b/src/modules/restaurant/views/HomePage.vue new file mode 100644 index 0000000..d31faf9 --- /dev/null +++ b/src/modules/restaurant/views/HomePage.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/restaurant-app/App.vue b/src/restaurant-app/App.vue new file mode 100644 index 0000000..f1e75b8 --- /dev/null +++ b/src/restaurant-app/App.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/restaurant-app/app.config.ts b/src/restaurant-app/app.config.ts new file mode 100644 index 0000000..a039c9a --- /dev/null +++ b/src/restaurant-app/app.config.ts @@ -0,0 +1,73 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Restaurant app configuration. + * + * Customer-facing surface for the LNbits "restaurant" extension. v1 + * is single-venue (URL-driven via `/r/:slug`); the bundle ships + * REST-only order placement. Festival/aggregator (NIP-51) and + * NIP-17 transport are deferred — see issues #8 / #9 on + * aiolabs/restaurant. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse( + import.meta.env.VITE_NOSTR_RELAYS || + '["wss://relay.damus.io", "wss://nos.lol"]' + ), + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true, + }, + imageUpload: { + baseUrl: + import.meta.env.VITE_PICTRS_BASE_URL || + 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/avif', + 'image/gif', + ], + }, + }, + }, + restaurant: { + name: 'restaurant', + enabled: true, + lazy: false, + config: { + apiBaseUrl: + import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000', + defaultSlug: import.meta.env.VITE_RESTAURANT_DEFAULT_SLUG || '', + orderPollMs: 5000, + currencyDisplay: 'sats' as const, + // Reserved for tier-gated UI (aiolabs/restaurant#2: + // bar/bistro/full operator modes). Future contributors set + // e.g. { inventoryPanel: true, loyaltyRewards: true } to + // unlock surfaces gated on the operator's tier. Empty in v1. + features: {} as Record, + }, + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV, + }, +} + +export default appConfig diff --git a/src/restaurant-app/app.ts b/src/restaurant-app/app.ts new file mode 100644 index 0000000..a6617b2 --- /dev/null +++ b/src/restaurant-app/app.ts @@ -0,0 +1,125 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import restaurantModule from '@/modules/restaurant' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { + installLenientAuthGuard, + markAuthReady, + catchAllRoute, +} from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' + +export async function createAppInstance() { + console.log('Starting Restaurant app...') + + acceptTokenFromUrl('Restaurant') + + const app = createApp(App) + + const moduleRoutes = [ + ...(baseModule.routes || []), + ...(restaurantModule.routes || []), + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + // The restaurant module owns `/` (HomePage handles redirect to + // the configured default slug or shows a discovery prompt); no + // top-level redirect like market needs. + { + path: '/login', + name: 'login', + component: + import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false }, + }, + ...moduleRoutes, + catchAllRoute, + ], + }) + + installLenientAuthGuard(router) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env + .VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.restaurant?.enabled) { + moduleRegistrations.push( + pluginManager.register(restaurantModule, appConfig.modules.restaurant) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + // Dynamic import: useAuthService depends on services registered by + // pluginManager.installAll() (LNbits API). + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + markAuthReady(auth) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('Restaurant app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Restaurant app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Restaurant app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/restaurant-app/main.ts b/src/restaurant-app/main.ts new file mode 100644 index 0000000..8579a6e --- /dev/null +++ b/src/restaurant-app/main.ts @@ -0,0 +1,20 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' +import 'vue-sonner/style.css' + +cleanupStaleDevServiceWorkers() + +const intervalMS = 60 * 60 * 1000 +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Restaurant app ready to work offline') + } +}) + +startApp() diff --git a/vite.restaurant.config.ts b/vite.restaurant.config.ts new file mode 100644 index 0000000..506655b --- /dev/null +++ b/vite.restaurant.config.ts @@ -0,0 +1,132 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' + +function restaurantHtmlPlugin(): Plugin { + return { + name: 'restaurant-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Strip query before checking for an extension — JWTs (e.g. ?token=...) + // contain dots and would otherwise get mistaken for an asset request. + const path = req.url ? req.url.split('?')[0] : '' + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !path.includes('.') + ) { + req.url = '/restaurant.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Restaurant customer app. + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/restaurant/ → app.${domain}/restaurant/ (shared auth) + * (default: /) → restaurant.${domain} (standalone subdomain) + * + * The companion server is the LNbits "restaurant" extension at + * ~/dev/shared/extensions/restaurant. v1 ships single-venue (URL-driven + * via /r/:slug); festival/aggregator and NIP-17 transport are tracked + * in repo issues #8 and #9 on aiolabs/restaurant. + */ +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + // Per-app dep cache so concurrent dev servers don't race on .vite/deps + cacheDir: 'node_modules/.vite-restaurant', + server: { + port: 5186, + strictPort: true, + }, + plugins: [ + restaurantHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { enabled: false }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'restaurant.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Restaurant — Order', + short_name: 'Restaurant', + description: 'Order from your local Nostr-native restaurant with Lightning payments', + // Green to differentiate from market red. PDF tile is purple + // (see ~/dev/shared/extensions/restaurant/static/image/restaurant.png). + theme_color: '#16a34a', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'restaurant-app', + categories: ['food', 'shopping'], + lang: 'en', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-restaurant/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + // ORDER MATTERS — @/app.config must precede @ (first-match-wins). + '@/app.config': fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)), + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist-restaurant', + rollupOptions: { + input: 'restaurant.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) -- 2.53.0 From 1cdf87b04ba7cbb089a749d3dd5754d2126f9785 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:16:32 +0200 Subject: [PATCH 2/9] feat(restaurant): types + RestaurantAPI REST client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit types/restaurant.ts — full set of TS interfaces hand-translated from ~/dev/shared/extensions/restaurant/models.py. Key notes: - Money is integer msat end-to-end on orders, order items, and the order quote response (matches the extension). - Open OrderStatus type with KNOWN_ORDER_STATUSES const so the production / kitchen workflow (aiolabs/restaurant#4) can introduce new states without breaking the build. - MenuItem.extra carries forward-compatible metadata for inventory (#3), happy-hour / COGS (#6), loyalty (#5), and mode-gated badges (#2). Plain Record. - OrderExtra.fields is the loyalty (#5) pass-through hook the useCheckout buildCreateOrder helper will inject through. - Restaurant.mode is acknowledged but not branched on in v1. services/RestaurantAPI.ts — BaseService subclass, mirrors the extension's REST surface: getRestaurantBySlug / getRestaurantById / getMenu / getMenuItem quoteOrder / placeOrder / getOrder No API key for any of these — public read and customer-facing write endpoints. Base URL pulled from appConfig.modules.restaurant.config.apiBaseUrl. modules/restaurant/index.ts — install() now constructs the API client, registers it under SERVICE_TOKENS.RESTAURANT_API, and kicks off .initialize(). Consumers (views, composables, stores) get the client via injectService starting in commit 4. --- src/modules/restaurant/index.ts | 18 +- .../restaurant/services/RestaurantAPI.ts | 170 ++++++++++ src/modules/restaurant/types/restaurant.ts | 308 ++++++++++++++++++ 3 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 src/modules/restaurant/services/RestaurantAPI.ts create mode 100644 src/modules/restaurant/types/restaurant.ts diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index 765231f..864e32c 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -1,6 +1,9 @@ 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' // v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug). // @@ -50,9 +53,18 @@ export const restaurantModule: ModulePlugin = { throw new Error('Restaurant module requires configuration') } - // Services (RestaurantAPI, RestaurantNostrSync) are wired in - // commits 3 and 8 respectively. v1 skeleton only registers - // the route table. + // 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) + }) + + // RestaurantNostrSync lands in commit 8. console.log('✅ Restaurant module installed') }, 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/types/restaurant.ts b/src/modules/restaurant/types/restaurant.ts new file mode 100644 index 0000000..87a61cf --- /dev/null +++ b/src/modules/restaurant/types/restaurant.ts @@ -0,0 +1,308 @@ +/** + * 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 + +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 +} -- 2.53.0 From 3a11d90164267afedc5394d231e6420dda9cef0a Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:20:47 +0200 Subject: [PATCH 3/9] feat(restaurant): menu browse views (Home + RestaurantPage + ItemPage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end menu browse for one restaurant. composables/useMenu(slugOrId) — fetches via REST. Resolves slug or id via heuristic, calls getMenu(), exposes {restaurant, tree, items, isLoading, error, refresh} as reactive refs. Cancels in-flight requests on param change /scope dispose. components: RestaurantHeader.vue — banner, logo, name, description, open badge, currency badge, location. CategoryNav.vue — sticky horizontal pill nav over root menu nodes; scrolls to anchors. MenuTree.vue — recursive renderer (self-references by name). Renders a node's items first, then its children — items can attach to any node per the menu-tree refactor. MenuItemCard.vue — image, name, price (msat-native via currencyHint), sold-out / low-stock / featured badges, dietary + allergen chips, '+' button that opens ItemPage or quick-adds when no modifier groups. ModifierSelector.vue — radio (selection='one') / checkbox (selection='many') with min/max enforcement. v-model-style emits (update:selected, update:valid). Seeds from is_default modifiers when no existing selection is passed. views: HomePage.vue — slug input + auto-redirect when VITE_RESTAURANT_DEFAULT_SLUG is set. RestaurantPage.vue — composite: header + CategoryNav + MenuTree. Loading / error states via shadcn Alert. ItemPage.vue — full item detail: image, dietary + allergen chips, ModifierSelector, note textarea, sticky bottom bar with qty stepper + 'Add to cart' CTA (disabled for v1; cart wires in commit 5). Routes registered on the module: /, /r/:slug, /r/:slug/item/:itemId. Design: shadcn-vue components throughout (Alert, Badge, Button, Card, Checkbox, Input, Label, RadioGroup, Textarea), Tailwind 4 utility classes, theme-aware semantic colors (text-foreground, bg-background, bg-card, text-muted-foreground, bg-primary, etc.). No raw hex or theme-blind classes. Verified: vue-tsc -b clean against the whole webapp. --- .../restaurant/components/CategoryNav.vue | 59 +++++ .../restaurant/components/MenuItemCard.vue | 149 +++++++++++ .../restaurant/components/MenuTree.vue | 82 ++++++ .../components/ModifierSelector.vue | 221 ++++++++++++++++ .../components/RestaurantHeader.vue | 81 ++++++ src/modules/restaurant/composables/useMenu.ts | 97 +++++++ src/modules/restaurant/index.ts | 12 + src/modules/restaurant/views/HomePage.vue | 95 +++++-- src/modules/restaurant/views/ItemPage.vue | 236 ++++++++++++++++++ .../restaurant/views/RestaurantPage.vue | 85 +++++++ 10 files changed, 1096 insertions(+), 21 deletions(-) create mode 100644 src/modules/restaurant/components/CategoryNav.vue create mode 100644 src/modules/restaurant/components/MenuItemCard.vue create mode 100644 src/modules/restaurant/components/MenuTree.vue create mode 100644 src/modules/restaurant/components/ModifierSelector.vue create mode 100644 src/modules/restaurant/components/RestaurantHeader.vue create mode 100644 src/modules/restaurant/composables/useMenu.ts create mode 100644 src/modules/restaurant/views/ItemPage.vue create mode 100644 src/modules/restaurant/views/RestaurantPage.vue 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/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/useMenu.ts b/src/modules/restaurant/composables/useMenu.ts new file mode 100644 index 0000000..b35e178 --- /dev/null +++ b/src/modules/restaurant/composables/useMenu.ts @@ -0,0 +1,97 @@ +/** + * 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, SERVICE_TOKENS } from '@/core/di-container' +import type { RestaurantAPI } from '../services/RestaurantAPI' +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: Ref + isLoading: Ref + error: Ref + refresh: () => Promise +} + +export function useMenu(slugOrId: Ref | string): UseMenuReturn { + const api = injectService(SERVICE_TOKENS.RESTAURANT_API) + + const restaurant = ref(null) + const tree = ref([]) + const items = ref([]) + const isLoading = ref(false) + const error = ref(null) + + let abortController: AbortController | null = null + + const target = computed(() => + typeof slugOrId === 'string' ? slugOrId : slugOrId.value + ) + + 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 + items.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/index.ts b/src/modules/restaurant/index.ts index 864e32c..dbec7da 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -76,6 +76,18 @@ export const restaurantModule: ModulePlugin = { 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' }, + }, ] as RouteRecordRaw[], } diff --git a/src/modules/restaurant/views/HomePage.vue b/src/modules/restaurant/views/HomePage.vue index d31faf9..5f71516 100644 --- a/src/modules/restaurant/views/HomePage.vue +++ b/src/modules/restaurant/views/HomePage.vue @@ -1,32 +1,85 @@ diff --git a/src/modules/restaurant/views/ItemPage.vue b/src/modules/restaurant/views/ItemPage.vue new file mode 100644 index 0000000..9d6e271 --- /dev/null +++ b/src/modules/restaurant/views/ItemPage.vue @@ -0,0 +1,236 @@ + + +