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 @@
+
+
+
+
+ Restaurant — Order
+
+ v1 skeleton. Real browse / cart / checkout land in commits 4–8.
+
+
+ Default venue:
+ {{ defaultSlug }}
+
+
+ Set VITE_RESTAURANT_DEFAULT_SLUG to
+ auto-redirect.
+
+
+
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,
+ },
+}))