feat(chatelet): guest booking UI — slice 1 (browse + availability) #142

Open
padreug wants to merge 2 commits from feat/chatelet-guest-ui into dev
8 changed files with 409 additions and 0 deletions
Showing only changes of commit 89a778e98b - Show all commits

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
Padreug 2026-07-20 16:43:23 +02:00

View file

@ -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

View 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 }
}

View 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'

View 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 HTTPRPC 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()
}
}

View 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 }
})

View 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
}

View 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>

View 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>