Compare commits
2 commits
c035fc2bfc
...
33cb75ac77
| Author | SHA1 | Date | |
|---|---|---|---|
| 33cb75ac77 | |||
| 89a778e98b |
16 changed files with 852 additions and 2 deletions
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",
|
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
|
||||||
"build:restaurant": "vue-tsc -b && vite build --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",
|
"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\"",
|
"dev:chatelet": "vite --host --config vite.chatelet.config.ts",
|
||||||
"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",
|
"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:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||||
"electron:package": "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>
|
||||||
|
|
@ -175,6 +175,9 @@ export const SERVICE_TOKENS = {
|
||||||
// Restaurant services
|
// Restaurant services
|
||||||
RESTAURANT_API: Symbol('restaurantAPI'),
|
RESTAURANT_API: Symbol('restaurantAPI'),
|
||||||
RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'),
|
RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'),
|
||||||
|
|
||||||
|
// Chatelet (room rentals) services
|
||||||
|
CHATELET_API: Symbol('chateletAPI'),
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Type-safe injection helpers
|
// Type-safe injection helpers
|
||||||
|
|
|
||||||
38
src/modules/chatelet/composables/useChatelet.ts
Normal file
38
src/modules/chatelet/composables/useChatelet.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
||||||
|
import type { ChateletApiService } from '../services/ChateletApiService'
|
||||||
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
import { useChateletStore } from '../stores/chatelet'
|
||||||
|
|
||||||
|
export function useChatelet() {
|
||||||
|
const store = useChateletStore()
|
||||||
|
const { rooms, isLoading } = storeToRefs(store)
|
||||||
|
// DI, never a direct import (workspace rule).
|
||||||
|
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
||||||
|
|
||||||
|
async function loadRooms(): Promise<void> {
|
||||||
|
if (!api) return
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
store.rooms = await api.getPublicRooms()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoom(id: string): Promise<Room | null> {
|
||||||
|
if (!api) return null
|
||||||
|
return api.getPublicRoom(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAvailability(
|
||||||
|
roomId: string,
|
||||||
|
checkIn: string,
|
||||||
|
checkOut: string,
|
||||||
|
): Promise<AvailabilityResult | null> {
|
||||||
|
if (!api) return null
|
||||||
|
return api.checkAvailability(roomId, checkIn, checkOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rooms, isLoading, loadRooms, getRoom, checkAvailability }
|
||||||
|
}
|
||||||
54
src/modules/chatelet/index.ts
Normal file
54
src/modules/chatelet/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
|
||||||
|
import { SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { ChateletApiService, type ChateletApiConfig } from './services/ChateletApiService'
|
||||||
|
|
||||||
|
export interface ChateletModuleConfig {
|
||||||
|
apiConfig: ChateletApiConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals
|
||||||
|
* extension. Slice 1: read-only discovery + availability over HTTP.
|
||||||
|
*/
|
||||||
|
export const chateletModule = createModulePlugin({
|
||||||
|
name: 'chatelet',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: ['base'],
|
||||||
|
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/chatelet',
|
||||||
|
name: 'chatelet',
|
||||||
|
component: () => import('./views/ChateletPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: (import.meta.env.VITE_APP_NAME as string) || 'Chatelet',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chatelet/:id',
|
||||||
|
name: 'chatelet-detail',
|
||||||
|
component: () => import('./views/ChateletDetailPage.vue'),
|
||||||
|
meta: { title: 'Room', requiresAuth: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
onInstall: async (_app, options) => {
|
||||||
|
const config = options?.config as ChateletModuleConfig | undefined
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Chatelet module requires configuration')
|
||||||
|
}
|
||||||
|
const { container } = await import('@/core/di-container')
|
||||||
|
const api = new ChateletApiService(config.apiConfig)
|
||||||
|
container.provide(SERVICE_TOKENS.CHATELET_API, api)
|
||||||
|
},
|
||||||
|
|
||||||
|
onUninstall: async () => {
|
||||||
|
const { container } = await import('@/core/di-container')
|
||||||
|
container.remove(SERVICE_TOKENS.CHATELET_API)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default chateletModule
|
||||||
|
|
||||||
|
export type { Room, AvailabilityResult } from './types/room'
|
||||||
69
src/modules/chatelet/services/ChateletApiService.ts
Normal file
69
src/modules/chatelet/services/ChateletApiService.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
|
||||||
|
export interface ChateletApiConfig {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client for the LNbits `chatelet` extension's guest surface.
|
||||||
|
*
|
||||||
|
* Plain config-wrapper (same pattern as events' TicketApiService) — not a
|
||||||
|
* BaseService. Slice 1 is read-only guest browsing over public endpoints;
|
||||||
|
* booking (POST /bookings) + status polling arrive in slice 2.
|
||||||
|
*
|
||||||
|
* Transport note: this is HTTP-for-now. The extension also exposes a
|
||||||
|
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
|
||||||
|
* the webapp's RPC client is disabled post-lnbits#9 (see aiolabs/lnbits#64).
|
||||||
|
* When that's revived, only this class swaps HTTP→RPC — no view changes.
|
||||||
|
*/
|
||||||
|
export class ChateletApiService {
|
||||||
|
constructor(private config: ChateletApiConfig) {}
|
||||||
|
|
||||||
|
/** Active rooms for guest browsing (operator-private fields stripped server-side). */
|
||||||
|
async getPublicRooms(): Promise<Room[]> {
|
||||||
|
return this.request('/chatelet/api/v1/public/rooms', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicRoom(id: string): Promise<Room> {
|
||||||
|
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAvailability(
|
||||||
|
roomId: string,
|
||||||
|
checkIn: string,
|
||||||
|
checkOut: string,
|
||||||
|
): Promise<AvailabilityResult> {
|
||||||
|
return this.request('/chatelet/api/v1/availability', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ room_id: roomId, check_in: checkIn, check_out: checkOut }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
// Guest endpoints are unauthenticated; only send a key if one is set.
|
||||||
|
...(this.config.apiKey ? { 'X-API-KEY': this.config.apiKey } : {}),
|
||||||
|
...((init.headers as Record<string, string>) ?? {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.baseUrl}${path}`, { ...init, headers })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: `Request failed: ${path}` }))
|
||||||
|
const message =
|
||||||
|
typeof error.detail === 'string'
|
||||||
|
? error.detail
|
||||||
|
: Array.isArray(error.detail)
|
||||||
|
? (error.detail[0]?.msg ?? 'Request failed')
|
||||||
|
: 'Request failed'
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/chatelet/stores/chatelet.ts
Normal file
9
src/modules/chatelet/stores/chatelet.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Room } from '../types/room'
|
||||||
|
|
||||||
|
export const useChateletStore = defineStore('chatelet', () => {
|
||||||
|
const rooms = ref<Room[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
return { rooms, isLoading }
|
||||||
|
})
|
||||||
33
src/modules/chatelet/types/room.ts
Normal file
33
src/modules/chatelet/types/room.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Public shapes returned by the chatelet extension's guest endpoints.
|
||||||
|
// Note: operator-private fields (wallet, checkin_instructions) are stripped
|
||||||
|
// server-side by public_room_dict and never reach the client.
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
price_amount: number
|
||||||
|
price_currency: string
|
||||||
|
price_frequency: string
|
||||||
|
max_guests: number
|
||||||
|
min_nights: number
|
||||||
|
amenities: string[]
|
||||||
|
location: string
|
||||||
|
geohash: string
|
||||||
|
images: string[]
|
||||||
|
status: string
|
||||||
|
listing_event_id: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailabilityResult {
|
||||||
|
room_id: string
|
||||||
|
check_in: string
|
||||||
|
check_out: string
|
||||||
|
available: boolean
|
||||||
|
nights: number
|
||||||
|
quote_sat: number | null
|
||||||
|
quote_fiat: number | null
|
||||||
|
currency: string | null
|
||||||
|
}
|
||||||
123
src/modules/chatelet/views/ChateletDetailPage.vue
Normal file
123
src/modules/chatelet/views/ChateletDetailPage.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { MapPin, Users } from 'lucide-vue-next'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useChatelet } from '../composables/useChatelet'
|
||||||
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { getRoom, checkAvailability } = useChatelet()
|
||||||
|
|
||||||
|
const room = ref<Room | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const checkIn = ref('')
|
||||||
|
const checkOut = ref('')
|
||||||
|
const checking = ref(false)
|
||||||
|
const result = ref<AvailabilityResult | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
room.value = await getRoom(route.params.id as string)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Could not load room')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onCheck() {
|
||||||
|
if (!room.value || !checkIn.value || !checkOut.value) return
|
||||||
|
checking.value = true
|
||||||
|
result.value = null
|
||||||
|
try {
|
||||||
|
result.value = await checkAvailability(room.value.id, checkIn.value, checkOut.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Availability check failed')
|
||||||
|
} finally {
|
||||||
|
checking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-2xl">
|
||||||
|
<div v-if="loading" class="text-muted-foreground py-12">Loading…</div>
|
||||||
|
<div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<img
|
||||||
|
v-if="room.images?.length"
|
||||||
|
:src="room.images[0]"
|
||||||
|
:alt="room.title"
|
||||||
|
class="w-full h-56 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">{{ room.title }}</h1>
|
||||||
|
<p
|
||||||
|
v-if="room.location"
|
||||||
|
class="text-muted-foreground flex items-center gap-1 mt-1"
|
||||||
|
>
|
||||||
|
<MapPin class="h-4 w-4" /> {{ room.location }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Users class="h-4 w-4" /> up to {{ room.max_guests }} guests · min
|
||||||
|
{{ room.min_nights }} night(s)
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold text-foreground mt-2">
|
||||||
|
{{ room.price_amount }} {{ room.price_currency }} / {{ room.price_frequency }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1">
|
||||||
|
<Badge v-for="a in room.amenities" :key="a" variant="outline">{{ a }}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="room.description" class="text-foreground whitespace-pre-line">
|
||||||
|
{{ room.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-4 space-y-3">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="ci">Check-in</Label>
|
||||||
|
<Input id="ci" type="date" v-model="checkIn" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="co">Check-out</Label>
|
||||||
|
<Input id="co" type="date" v-model="checkOut" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!checkIn || !checkOut || checking"
|
||||||
|
@click="onCheck"
|
||||||
|
>
|
||||||
|
{{ checking ? 'Checking…' : 'Check availability' }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div v-if="result" class="text-sm pt-1">
|
||||||
|
<template v-if="result.available">
|
||||||
|
<p class="text-foreground">
|
||||||
|
Available for {{ result.nights }} night(s) —
|
||||||
|
<span class="font-semibold">{{ result.quote_sat }} sats</span>
|
||||||
|
<span v-if="result.quote_fiat" class="text-muted-foreground">
|
||||||
|
(≈ {{ result.quote_fiat }} {{ result.currency }})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<Button class="w-full mt-3" disabled>Book — coming soon</Button>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-destructive">Not available for those dates.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
80
src/modules/chatelet/views/ChateletPage.vue
Normal file
80
src/modules/chatelet/views/ChateletPage.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { MapPin, Users } from 'lucide-vue-next'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useChatelet } from '../composables/useChatelet'
|
||||||
|
import type { Room } from '../types/room'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { rooms, isLoading, loadRooms } = useChatelet()
|
||||||
|
|
||||||
|
onMounted(loadRooms)
|
||||||
|
|
||||||
|
function open(id: string) {
|
||||||
|
router.push(`/chatelet/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceLabel(r: Room): string {
|
||||||
|
return `${r.price_amount} ${r.price_currency} / ${r.price_frequency}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground mb-6">Rooms</h1>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Skeleton v-for="n in 4" :key="n" class="h-64 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="rooms.length === 0" class="text-muted-foreground text-center py-16">
|
||||||
|
No rooms available right now.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Card
|
||||||
|
v-for="room in rooms"
|
||||||
|
:key="room.id"
|
||||||
|
class="cursor-pointer overflow-hidden hover:border-ring transition-colors"
|
||||||
|
@click="open(room.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="room.images?.length"
|
||||||
|
:src="room.images[0]"
|
||||||
|
:alt="room.title"
|
||||||
|
class="h-40 w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="h-40 w-full bg-muted flex items-center justify-center text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
<CardContent class="p-4">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h3 class="font-semibold text-foreground">{{ room.title }}</h3>
|
||||||
|
<Badge variant="secondary" class="shrink-0">{{ priceLabel(room) }}</Badge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="room.location"
|
||||||
|
class="text-sm text-muted-foreground mt-1 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MapPin class="h-3.5 w-3.5" /> {{ room.location }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1 flex items-center gap-1">
|
||||||
|
<Users class="h-3.5 w-3.5" /> up to {{ room.max_guests }} guests
|
||||||
|
</p>
|
||||||
|
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Badge v-for="a in room.amenities.slice(0, 4)" :key="a" variant="outline">
|
||||||
|
{{ a }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</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