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, + }, +}))