feat(chatelet): book a room — hold, Lightning invoice, paid confirmation

Slice 2 of the guest UI. The Book button on the room page opens a
dialog that collects guest count, optional contact and a note, asks
the extension to hold the dates, then shows the bolt11 (QR, copy,
open-in-wallet, pay from the LNbits wallet) and polls settlement
until it is paid. useBookingFlow mirrors events' useTicketPurchase.

Booking needs a Nostr identity (the extension DMs check-in details to
the guest pubkey), so anonymous guests bounce through
/login?redirect= and land back on the room with their dates in the
query. Guest checkout without an account is a later slice.

Settlement is polled on LNbits core's anonymous payment status until
the extension grows a public booking-status endpoint; the paid-invoice
listener on the extension is what confirms the booking server-side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-15 23:19:05 +02:00
commit c369beab92
3 changed files with 535 additions and 2 deletions

View file

@ -0,0 +1,332 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { toast } from 'vue-sonner'
import { format } from 'date-fns'
import { Check, CheckCircle2, Copy, Loader2, Wallet, Zap } from 'lucide-vue-next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useAuth } from '@/composables/useAuthService'
import { formatSats } from '@/lib/utils/formatting'
import { useBookingFlow } from '../composables/useBookingFlow'
import type { AvailabilityResult, Room } from '../types/room'
interface Props {
room: Room
/** The availability quote the guest just saw — dates + nights + price. */
availability: AvailabilityResult
isOpen: boolean
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
/** Fired once when the invoice settles, before the dialog closes. */
(e: 'booked', bookingId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { currentUser } = useAuth()
const flow = useBookingFlow()
// --- Guest details form --------------------------------------------------
const formSchema = computed(() =>
toTypedSchema(
z.object({
num_guests: z.coerce
.number()
.int()
.min(1, 'At least one guest')
.max(props.room.max_guests, `This room sleeps up to ${props.room.max_guests}`),
guest_contact: z.string().max(200, 'Too long').optional(),
message: z.string().max(1000, 'Too long').optional(),
}),
),
)
const form = useForm({
validationSchema: formSchema,
initialValues: { num_guests: 1, guest_contact: '', message: '' },
})
const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => {
const pubkey = currentUser.value?.pubkey
if (!pubkey) {
toast.error('Sign in to book a room')
return
}
try {
await flow.request({
room_id: props.room.id,
guest_pubkey: pubkey,
check_in: props.availability.check_in,
check_out: props.availability.check_out,
num_guests: values.num_guests,
guest_contact: values.guest_contact?.trim() || undefined,
message: values.message?.trim() || undefined,
})
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Could not request the booking')
}
})
// --- Display helpers -----------------------------------------------------
function longDate(ymd: string): string {
return format(new Date(ymd + 'T00:00:00'), 'EEE, MMM d')
}
const stayLabel = computed(
() => `${longDate(props.availability.check_in)} → ${longDate(props.availability.check_out)}`,
)
const nightsLabel = computed(
() => `${props.availability.nights} night${props.availability.nights === 1 ? '' : 's'}`,
)
/** Hold expiry as a wall-clock time, so the guest knows how long the invoice lives. */
const holdExpiry = computed(() => {
const iso = flow.booking.value?.expires_at
if (!iso) return ''
const d = new Date(iso)
return isNaN(d.getTime()) ? '' : format(d, 'p')
})
const step = computed<'form' | 'invoice' | 'confirmed'>(() => {
if (flow.isPaid.value) return 'confirmed'
if (flow.quote.value) return 'invoice'
return 'form'
})
watch(
() => flow.isPaid.value,
(paid) => {
if (paid && flow.booking.value) emit('booked', flow.booking.value.id)
},
)
// --- Open / close --------------------------------------------------------
const confirmClose = ref(false)
function handleClose(open: boolean) {
if (open) return
// Closing mid-invoice abandons the hold; it expires on its own, but
// make the guest confirm so a stray tap doesn't lose the QR.
if (step.value === 'invoice' && !confirmClose.value) {
confirmClose.value = true
return
}
confirmClose.value = false
emit('update:isOpen', false)
}
watch(
() => props.isOpen,
(open) => {
if (open) {
flow.reset()
confirmClose.value = false
form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } })
} else {
flow.reset()
}
},
)
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{{ room.title }}</DialogTitle>
<DialogDescription>
{{ stayLabel }} · {{ nightsLabel }}
</DialogDescription>
</DialogHeader>
<!-- Step 1: who's coming -->
<form v-if="step === 'form'" class="space-y-4" @submit="onSubmit">
<div class="rounded-lg bg-muted/50 p-3 text-sm flex items-baseline justify-between">
<span class="text-muted-foreground">Total</span>
<span class="font-semibold text-foreground">
<template v-if="availability.quote_sat != null">
{{ formatSats(availability.quote_sat) }}
</template>
<span
v-if="availability.quote_fiat != null"
class="text-muted-foreground font-normal"
>
(≈ {{ availability.quote_fiat }} {{ availability.currency }})
</span>
</span>
</div>
<FormField v-slot="{ componentField }" name="num_guests">
<FormItem>
<FormLabel>Guests</FormLabel>
<FormControl>
<Input type="number" min="1" :max="room.max_guests" v-bind="componentField" />
</FormControl>
<FormDescription>Sleeps up to {{ room.max_guests }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="guest_contact">
<FormItem>
<FormLabel>
Contact
<span class="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="Email or phone, in case the host needs to reach you" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>
Note to the host
<span class="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<Textarea rows="3" placeholder="Arrival time, questions…" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" class="w-full" :disabled="!isFormValid || flow.isRequesting.value">
<Loader2 v-if="flow.isRequesting.value" class="mr-2 h-4 w-4 animate-spin" />
{{ flow.isRequesting.value ? 'Holding your dates…' : 'Continue to payment' }}
</Button>
</form>
<!-- Step 2: pay the invoice (events / restaurant invoice-card pattern) -->
<div v-else-if="step === 'invoice'" class="py-2 space-y-4">
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay to confirm</h3>
<p class="text-sm text-muted-foreground">
<template v-if="flow.hasWalletWithBalance.value">
Scan with any Lightning wallet, or pay from your LNbits wallet below.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p>
</div>
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<!-- White background regardless of theme so phone cameras parse it. -->
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img
v-if="flow.qrCode.value"
:src="flow.qrCode.value"
alt="Lightning payment QR code"
class="block h-56 w-56 sm:h-64 sm:w-64"
/>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary">
{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}
</span>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" class="flex-1 font-mono text-xs" @click="flow.copyInvoice">
<Check v-if="flow.copiedInvoice.value" class="mr-2 h-3.5 w-3.5" />
<Copy v-else class="mr-2 h-3.5 w-3.5" />
{{ flow.copiedInvoice.value ? 'Copied' : 'Copy' }}
</Button>
<Button variant="default" size="sm" class="flex-1 text-xs" @click="flow.openExternalWallet">
<Zap class="mr-2 h-3.5 w-3.5" />
Open in wallet
</Button>
</div>
</div>
<Button
v-if="flow.hasWalletWithBalance.value"
size="lg"
class="w-full"
:disabled="flow.isPayingWithWallet.value"
@click="flow.payWithWallet"
>
<Loader2 v-if="flow.isPayingWithWallet.value" class="mr-2 h-4 w-4 animate-spin" />
<Wallet v-else class="mr-2 h-4 w-4" />
{{ flow.isPayingWithWallet.value ? 'Paying…' : 'Pay from my LNbits wallet' }}
</Button>
<p
v-else-if="flow.userWallets.value.length > 0"
class="text-center text-xs text-muted-foreground"
>
Your LNbits wallet is empty — pay with an external wallet using the QR
or "Open in wallet" above.
</p>
<div v-if="flow.isPaymentPending.value" class="text-center space-y-1">
<div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground">Waiting for payment…</span>
</div>
<p class="text-xs text-muted-foreground">
Your dates are held<template v-if="holdExpiry"> until {{ holdExpiry }}</template>.
Confirmation lands automatically — no need to refresh.
</p>
</div>
<p v-if="confirmClose" class="text-center text-xs text-destructive">
Close without paying? The hold expires on its own. Tap outside again to close.
</p>
</div>
<!-- Step 3: paid -->
<div v-else class="py-4 space-y-4 text-center">
<CheckCircle2 class="mx-auto h-12 w-12 text-primary" />
<div class="space-y-1">
<h3 class="text-lg font-semibold">You're booked</h3>
<p class="text-sm text-muted-foreground">
{{ stayLabel }} · {{ nightsLabel }}
</p>
</div>
<div class="rounded-lg bg-muted/50 p-3 text-sm space-y-1 text-left">
<div class="flex justify-between">
<span class="text-muted-foreground">Booking</span>
<span class="font-mono">{{ flow.booking.value?.id }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Paid</span>
<span class="font-semibold">{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}</span>
</div>
</div>
<p class="text-xs text-muted-foreground">
Check-in details arrive as a private Nostr message from the host.
</p>
<Button class="w-full" @click="emit('update:isOpen', false)">Done</Button>
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,143 @@
import { computed, onUnmounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
import type { ChateletApiService } from '../services/ChateletApiService'
import type { BookingQuote, BookingRequest } from '../types/booking'
const POLL_MS = 2000
/**
* Drives one booking from request → invoice → paid. Modeled on events'
* useTicketPurchase: the backend holds the dates and returns a bolt11;
* this composable renders it (QR / copy / open-in-wallet / pay from the
* LNbits wallet) and polls settlement until the invoice is paid. The
* extension's paid-invoice listener is what confirms the booking
* server-side — the guest only needs to see "paid" to know the dates
* are theirs.
*/
export function useBookingFlow() {
// DI, never a direct import (workspace rule).
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE)
const quote = ref<BookingQuote | null>(null)
const qrCode = ref<string | null>(null)
const isRequesting = ref(false)
const isPaymentPending = ref(false)
const isPaid = ref(false)
const copiedInvoice = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const paymentRequest = computed(() => quote.value?.payment_request ?? '')
const booking = computed(() => quote.value?.booking ?? null)
const userWallets = computed(() => paymentService?.userWallets ?? [])
const hasWalletWithBalance = computed(() => paymentService?.hasWalletWithBalance ?? false)
const isPayingWithWallet = computed(() => paymentService?.isProcessingPayment.value ?? false)
/** Hold the dates and get the invoice; starts the settlement poll. */
async function request(data: BookingRequest): Promise<BookingQuote | null> {
if (!api) return null
isRequesting.value = true
try {
const q = await api.requestBooking(data)
quote.value = q
try {
qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null
} catch (err) {
console.error('Error generating QR code:', err)
}
startPolling(q.payment_hash)
return q
} finally {
isRequesting.value = false
}
}
function startPolling(hash: string) {
stopPolling()
isPaymentPending.value = true
const check = async () => {
try {
const status = await api?.getPaymentStatus(hash)
if (status?.paid) {
isPaid.value = true
stopPolling()
}
} catch (err) {
// Transient network errors just mean "try again next tick".
console.error('Error checking booking payment status:', err)
}
}
void check()
pollTimer = setInterval(check, POLL_MS)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
isPaymentPending.value = false
}
async function copyInvoice() {
if (!paymentRequest.value) return
try {
await navigator.clipboard.writeText(paymentRequest.value)
copiedInvoice.value = true
setTimeout(() => (copiedInvoice.value = false), 1500)
} catch {
// Insecure context / old browser — "Open in wallet" still works.
}
}
function openExternalWallet() {
if (paymentRequest.value) paymentService?.openExternalWallet(paymentRequest.value)
}
/** Pay from the logged-in user's LNbits wallet; the poll picks up settlement. */
async function payWithWallet() {
if (!paymentService || !paymentRequest.value) return
try {
await paymentService.payWithWallet(paymentRequest.value, booking.value?.deposit_sat, {
showToast: false,
})
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Wallet payment failed')
}
}
function reset() {
stopPolling()
quote.value = null
qrCode.value = null
isPaid.value = false
copiedInvoice.value = false
}
onUnmounted(stopPolling)
return {
quote,
booking,
paymentRequest,
qrCode,
isRequesting,
isPaymentPending,
isPaid,
copiedInvoice,
userWallets,
hasWalletWithBalance,
isPayingWithWallet,
request,
copyInvoice,
openExternalWallet,
payWithWallet,
reset,
}
}

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { addDays, format } from 'date-fns'
import { MapPin, Users } from 'lucide-vue-next'
@ -9,10 +9,14 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import DatePicker from '@/modules/base/components/DatePicker.vue'
import { useAuth } from '@/composables/useAuthService'
import { useChatelet } from '../composables/useChatelet'
import BookRoomDialog from '../components/BookRoomDialog.vue'
import type { AvailabilityResult, Room } from '../types/room'
const route = useRoute()
const router = useRouter()
const { isAuthenticated } = useAuth()
const { getRoom, checkAvailability } = useChatelet()
const room = ref<Room | null>(null)
@ -21,6 +25,10 @@ const checkIn = ref('')
const checkOut = ref('')
const checking = ref(false)
const result = ref<AvailabilityResult | null>(null)
const showBooking = ref(false)
/** Quote snapshot the dialog books against — survives the page's own
* `result` being cleared once the dates are taken. */
const bookingQuote = ref<AvailabilityResult | null>(null)
// Same YYYY-MM-DD wire format the DatePicker speaks; the availability
// endpoint takes the same strings.
@ -42,6 +50,8 @@ watch(checkOut, () => {
result.value = null
})
const YMD = /^\d{4}-\d{2}-\d{2}$/
onMounted(async () => {
try {
room.value = await getRoom(route.params.id as string)
@ -50,6 +60,14 @@ onMounted(async () => {
} finally {
loading.value = false
}
// Dates survive a trip through /login (see onBook) as query params.
const qi = route.query.check_in
const qo = route.query.check_out
if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) {
checkIn.value = qi
checkOut.value = qo
if (room.value) await onCheck()
}
})
async function onCheck() {
@ -64,6 +82,36 @@ async function onCheck() {
checking.value = false
}
}
/**
* Booking needs a Nostr identity (the extension DMs check-in details to
* the guest pubkey), so anonymous guests bounce through /login and come
* back here with their dates intact. Guest checkout is a later slice.
*/
function onBook() {
if (!result.value?.available) return
if (!isAuthenticated.value) {
const back = router.resolve({
path: route.path,
query: { check_in: checkIn.value, check_out: checkOut.value },
}).fullPath
router.push({ path: '/login', query: { redirect: back } })
return
}
bookingQuote.value = result.value
showBooking.value = true
}
/** The dates are taken now — drop the stale quote so the card resets. */
function onBooked() {
result.value = null
checkIn.value = ''
checkOut.value = ''
}
watch(showBooking, (open) => {
if (!open) bookingQuote.value = null
})
</script>
<template>
@ -138,12 +186,22 @@ async function onCheck() {
(≈ {{ result.quote_fiat }} {{ result.currency }})
</span>
</p>
<Button class="w-full mt-3" disabled>Book — coming soon</Button>
<Button class="w-full mt-3" @click="onBook">
{{ isAuthenticated ? 'Book' : 'Sign in to book' }}
</Button>
</template>
<p v-else class="text-destructive">Not available for those dates.</p>
</div>
</CardContent>
</Card>
</div>
<BookRoomDialog
v-if="room && bookingQuote"
:room="room"
:availability="bookingQuote"
v-model:is-open="showBooking"
@booked="onBooked"
/>
</div>
</template>