diff --git a/chatelet.html b/chatelet.html
new file mode 100644
index 0000000..b142652
--- /dev/null
+++ b/chatelet.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+ %VITE_APP_NAME%
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index d40f262..8604d9e 100644
--- a/package.json
+++ b/package.json
@@ -36,8 +36,11 @@
"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,events,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"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=/events/ npm run build:events && 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:chatelet": "vite --host --config vite.chatelet.config.ts",
+ "build:chatelet": "vue-tsc -b && vite build --config vite.chatelet.config.ts",
+ "preview:chatelet": "vite preview --host --config vite.chatelet.config.ts",
+ "dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant,chatelet -c blue,magenta,cyan,yellow,green,blue,red,gray,green,cyan \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\" \"npm:dev:chatelet\"",
+ "build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && 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 && VITE_BASE_PATH=/chatelet/ npm run build:chatelet",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder",
diff --git a/src/chatelet-app/App.vue b/src/chatelet-app/App.vue
new file mode 100644
index 0000000..0b1977e
--- /dev/null
+++ b/src/chatelet-app/App.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/src/chatelet-app/app.config.ts b/src/chatelet-app/app.config.ts
new file mode 100644
index 0000000..da75c6f
--- /dev/null
+++ b/src/chatelet-app/app.config.ts
@@ -0,0 +1,54 @@
+import type { AppConfig } from '@/core/types'
+
+/**
+ * Standalone chatelet app configuration.
+ * Enables base + chatelet modules only.
+ */
+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'],
+ },
+ },
+ },
+ chatelet: {
+ name: 'chatelet',
+ enabled: true,
+ lazy: false,
+ config: {
+ apiConfig: {
+ baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
+ apiKey: import.meta.env.VITE_API_KEY || '',
+ },
+ },
+ },
+ },
+
+ features: {
+ pwa: true,
+ pushNotifications: true,
+ electronApp: false,
+ developmentMode: import.meta.env.DEV,
+ },
+}
+
+export default appConfig
diff --git a/src/chatelet-app/app.ts b/src/chatelet-app/app.ts
new file mode 100644
index 0000000..13a76af
--- /dev/null
+++ b/src/chatelet-app/app.ts
@@ -0,0 +1,128 @@
+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 chateletModule from '@/modules/chatelet'
+
+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'
+
+const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Chatelet'
+const APP_LABEL =
+ APP_NAME.toLowerCase() === 'chatelet' ? 'Chatelet' : `Chatelet (${APP_NAME})`
+
+/**
+ * Initialize the standalone chatelet app (guest room booking).
+ */
+export async function createAppInstance() {
+ console.log(`🚀 Starting ${APP_LABEL}...`)
+
+ // Accept token from URL before anything else (cross-subdomain auth relay)
+ acceptTokenFromUrl(APP_NAME)
+
+ const app = createApp(App)
+
+ const moduleRoutes = [
+ ...(baseModule.routes || []),
+ ...(chateletModule.routes || []),
+ ].filter(Boolean)
+
+ const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes: [
+ { path: '/', redirect: '/chatelet' },
+ {
+ path: '/login',
+ name: 'login',
+ component:
+ import.meta.env.VITE_DEMO_MODE === 'true'
+ ? () => import('@/pages/LoginDemo.vue')
+ : () => import('@/pages/Login.vue'),
+ meta: { requiresAuth: false },
+ },
+ ...moduleRoutes,
+ {
+ path: '/settings',
+ name: 'settings',
+ component: () => import('./views/SettingsPage.vue'),
+ meta: { requiresAuth: false },
+ },
+ 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.chatelet?.enabled) {
+ moduleRegistrations.push(
+ pluginManager.register(chateletModule, appConfig.modules.chatelet),
+ )
+ }
+
+ await Promise.all(moduleRegistrations)
+ await pluginManager.installAll()
+
+ 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(`✅ ${APP_LABEL} initialized`)
+ return { app, router }
+}
+
+export async function startApp() {
+ try {
+ const { app } = await createAppInstance()
+ app.mount('#app')
+ console.log(`🎉 ${APP_LABEL} started!`)
+ eventBus.emit('app:started', {}, 'app')
+ } catch (error) {
+ console.error(`💥 Failed to start ${APP_LABEL}:`, error)
+ document.getElementById('app')!.innerHTML = `
+
+
Failed to Start
+
${error instanceof Error ? error.message : 'Unknown error'}
+
Please refresh the page.
+
+ `
+ }
+}
diff --git a/src/chatelet-app/main.ts b/src/chatelet-app/main.ts
new file mode 100644
index 0000000..790c248
--- /dev/null
+++ b/src/chatelet-app/main.ts
@@ -0,0 +1,24 @@
+import { startApp } from './app'
+import { registerSW } from 'virtual:pwa-register'
+import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
+import 'vue-sonner/style.css'
+
+cleanupStaleDevServiceWorkers()
+
+// PWA service worker with periodic updates
+const intervalMS = 60 * 60 * 1000 // 1 hour
+registerSW({
+ onRegistered(r) {
+ r &&
+ setInterval(() => {
+ r.update()
+ }, intervalMS)
+ },
+ onOfflineReady() {
+ console.log(
+ `${(import.meta.env.VITE_APP_NAME as string) || 'Chatelet'} ready to work offline`,
+ )
+ },
+})
+
+startApp()
diff --git a/src/chatelet-app/views/SettingsPage.vue b/src/chatelet-app/views/SettingsPage.vue
new file mode 100644
index 0000000..22fcde0
--- /dev/null
+++ b/src/chatelet-app/views/SettingsPage.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
Settings
+
+
+
+ Theme
+
+
+
+
+
+
+
Signed in as
+
{{ userPubkey }}
+
+
+
+
+
+
+
+
diff --git a/vite.chatelet.config.ts b/vite.chatelet.config.ts
new file mode 100644
index 0000000..0d69fb8
--- /dev/null
+++ b/vite.chatelet.config.ts
@@ -0,0 +1,142 @@
+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'
+import {
+ brand,
+ brandAlias,
+ brandAppBannerAliasEntry,
+ brandAppLogoAliasEntry,
+ brandAssetsPlugin,
+ brandHubLogoAliasEntry,
+ brandManifestName,
+ resolveAppBanner,
+} from './vite-branding'
+
+/**
+ * SPA fallback: rewrite dev-server requests to chatelet.html.
+ */
+function chateletHtmlPlugin(): Plugin {
+ return {
+ name: 'chatelet-html-rewrite',
+ configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ 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 = '/chatelet.html'
+ }
+ next()
+ })
+ },
+ }
+}
+
+/**
+ * Vite config for the standalone chatelet app (guest room booking).
+ *
+ * Set VITE_BASE_PATH to deploy under a path prefix:
+ * VITE_BASE_PATH=/chatelet/ → app.domain/chatelet/ (shared auth)
+ * (default: /) → standalone subdomain
+ */
+const APP_NAME = brandManifestName()
+process.env.VITE_APP_NAME = APP_NAME
+process.env.VITE_APP_BANNER = resolveAppBanner('chatelet') ? '1' : ''
+
+export default defineConfig(({ mode }) => ({
+ base: process.env.VITE_BASE_PATH || '/',
+ cacheDir: 'node_modules/.vite-chatelet',
+ server: {
+ port: 5188,
+ strictPort: true,
+ },
+ plugins: [
+ brandAssetsPlugin(),
+ chateletHtmlPlugin(),
+ vue(),
+ tailwindcss(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ devOptions: {
+ enabled: false,
+ },
+ workbox: {
+ globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
+ navigateFallback: 'chatelet.html',
+ navigateFallbackAllowlist: [
+ new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
+ ],
+ },
+ includeAssets: [
+ 'icons/favicon.ico',
+ 'icons/apple-touch-icon.png',
+ 'icons/icon-192.png',
+ 'icons/icon-512.png',
+ 'icons/icon-maskable-192.png',
+ 'icons/icon-maskable-512.png',
+ ],
+ manifest: {
+ name: APP_NAME,
+ short_name: brand.shortName ?? APP_NAME,
+ description: 'Book a room',
+ theme_color: brand.themeColor ?? '#1f2937',
+ background_color: brand.backgroundColor ?? '#ffffff',
+ display: 'standalone',
+ orientation: 'portrait-primary',
+ start_url: process.env.VITE_BASE_PATH || '/',
+ scope: process.env.VITE_BASE_PATH || '/',
+ id: 'aiolabs-chatelet',
+ categories: ['travel', 'lifestyle'],
+ icons: [
+ { src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
+ { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
+ { src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
+ { src: 'icons/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-chatelet/stats.html',
+ gzipSize: true,
+ brotliSize: true,
+ }),
+ ],
+ resolve: {
+ alias: [
+ brandAppLogoAliasEntry('chatelet'),
+ brandAppBannerAliasEntry('chatelet'),
+ brandHubLogoAliasEntry(),
+ ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
+ { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
+ ],
+ },
+ build: {
+ outDir: 'dist-chatelet',
+ rollupOptions: {
+ input: 'chatelet.html',
+ output: {
+ manualChunks: {
+ 'vue-vendor': ['vue', 'vue-router', 'pinia'],
+ 'ui-vendor': ['radix-vue', '@vueuse/core'],
+ 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
+ },
+ },
+ },
+ chunkSizeWarningLimit: 1000,
+ },
+}))