diff --git a/src/core/di-container.ts b/src/core/di-container.ts index f406812..a038a43 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -175,6 +175,9 @@ export const SERVICE_TOKENS = { // Restaurant services RESTAURANT_API: Symbol('restaurantAPI'), RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'), + + // Chatelet (room rentals) services + CHATELET_API: Symbol('chateletAPI'), } as const // Type-safe injection helpers diff --git a/src/modules/chatelet/composables/useChatelet.ts b/src/modules/chatelet/composables/useChatelet.ts new file mode 100644 index 0000000..c93fc6d --- /dev/null +++ b/src/modules/chatelet/composables/useChatelet.ts @@ -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(SERVICE_TOKENS.CHATELET_API) + + async function loadRooms(): Promise { + if (!api) return + isLoading.value = true + try { + store.rooms = await api.getPublicRooms() + } finally { + isLoading.value = false + } + } + + async function getRoom(id: string): Promise { + if (!api) return null + return api.getPublicRoom(id) + } + + async function checkAvailability( + roomId: string, + checkIn: string, + checkOut: string, + ): Promise { + if (!api) return null + return api.checkAvailability(roomId, checkIn, checkOut) + } + + return { rooms, isLoading, loadRooms, getRoom, checkAvailability } +} diff --git a/src/modules/chatelet/index.ts b/src/modules/chatelet/index.ts new file mode 100644 index 0000000..60653c9 --- /dev/null +++ b/src/modules/chatelet/index.ts @@ -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' diff --git a/src/modules/chatelet/services/ChateletApiService.ts b/src/modules/chatelet/services/ChateletApiService.ts new file mode 100644 index 0000000..015252a --- /dev/null +++ b/src/modules/chatelet/services/ChateletApiService.ts @@ -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 { + return this.request('/chatelet/api/v1/public/rooms', { method: 'GET' }) + } + + async getPublicRoom(id: string): Promise { + return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' }) + } + + async checkAvailability( + roomId: string, + checkIn: string, + checkOut: string, + ): Promise { + 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 { + const headers: Record = { + 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) ?? {}), + } + + 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() + } +} diff --git a/src/modules/chatelet/stores/chatelet.ts b/src/modules/chatelet/stores/chatelet.ts new file mode 100644 index 0000000..86a857f --- /dev/null +++ b/src/modules/chatelet/stores/chatelet.ts @@ -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([]) + const isLoading = ref(false) + return { rooms, isLoading } +}) diff --git a/src/modules/chatelet/types/room.ts b/src/modules/chatelet/types/room.ts new file mode 100644 index 0000000..6800d6a --- /dev/null +++ b/src/modules/chatelet/types/room.ts @@ -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 +} diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue new file mode 100644 index 0000000..8b8f7e7 --- /dev/null +++ b/src/modules/chatelet/views/ChateletDetailPage.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/modules/chatelet/views/ChateletPage.vue b/src/modules/chatelet/views/ChateletPage.vue new file mode 100644 index 0000000..04418ff --- /dev/null +++ b/src/modules/chatelet/views/ChateletPage.vue @@ -0,0 +1,80 @@ + + +