feat(chatelet): guest booking UI — slice 1 (browse + availability) #142
8 changed files with 443 additions and 2 deletions
feat(chatelet): standalone app scaffold (second Vite entry)
chatelet.html + src/chatelet-app/{main,app,App,app.config,views/SettingsPage}
+ vite.chatelet.config.ts (port 5188, cacheDir .vite-chatelet, dist-chatelet,
manifest id aiolabs-chatelet), mirroring the events standalone. package.json
gains dev:/build:/preview:chatelet + entries in dev:all and build:demo
(VITE_BASE_PATH=/chatelet/). Verified: build:chatelet typechecks + bundles clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
commit
33cb75ac77
19
chatelet.html
Normal file
19
chatelet.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>%VITE_APP_NAME%</title>
|
||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||
<meta name="description" content="Book a room">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/chatelet-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
16
src/chatelet-app/App.vue
Normal file
16
src/chatelet-app/App.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
// Chatelet's in-page navigation is route-driven (browse → detail); the shell
|
||||
// only contributes the always-on Profile entry on the bottom row.
|
||||
const tabs: BottomTab[] = []
|
||||
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
54
src/chatelet-app/app.config.ts
Normal file
54
src/chatelet-app/app.config.ts
Normal file
|
|
@ -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
|
||||
128
src/chatelet-app/app.ts
Normal file
128
src/chatelet-app/app.ts
Normal file
|
|
@ -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 = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
24
src/chatelet-app/main.ts
Normal file
24
src/chatelet-app/main.ts
Normal file
|
|
@ -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()
|
||||
55
src/chatelet-app/views/SettingsPage.vue
Normal file
55
src/chatelet-app/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
|
||||
const { theme, setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
|
||||
const isAuthenticated = computed(() => auth.isAuthenticated.value)
|
||||
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-foreground">Theme</span>
|
||||
<Button variant="outline" size="sm" @click="toggleTheme">
|
||||
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
|
||||
<Moon v-else class="h-4 w-4" />
|
||||
<span class="ml-2">{{ theme === 'dark' ? 'Light' : 'Dark' }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div v-if="isAuthenticated" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">Signed in as</p>
|
||||
<p class="text-sm text-foreground break-all">{{ userPubkey }}</p>
|
||||
<Button variant="outline" size="sm" @click="handleLogout">
|
||||
<LogOut class="h-4 w-4 mr-2" /> Log out
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Button variant="outline" size="sm" @click="router.push('/login')">
|
||||
<LogIn class="h-4 w-4 mr-2" /> Log in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
142
vite.chatelet.config.ts
Normal file
142
vite.chatelet.config.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue