feat(chatelet): guest module — ChateletApiService + browse/availability views
Slice-1 part B of the chatelet guest booking UI (aiolabs/webapp #141). - ChateletApiService: plain HTTP wrapper (events TicketApiService pattern) → chatelet's public guest endpoints (/public/rooms, /availability). Unauth by default; swaps HTTP→kind-21000 RPC once aiolabs/lnbits#64 lands, no view change. - useChatelet composable + pinia store (DI via tryInjectService, never direct import). - Views: ChateletPage (room grid) + ChateletDetailPage (details + date-range availability check with sats/fiat quote; Book button stubbed for slice 2). - SERVICE_TOKENS.CHATELET_API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
parent
c035fc2bfc
commit
89a778e98b
8 changed files with 409 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue