From 8d9a31ca1ce8046ac29acc850191fef860434286 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 21 Sep 2026 15:04:06 +0200 Subject: [PATCH] feat(chatelet): let a guest resume a held-but-unpaid booking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the invoice dialog left the guest stranded: the hold blocks the room until it expires, and the public booking read-back has no bolt11, so there was no way back to the invoice — they had to wait out the hold to try the room again. Persist each hold's invoice on the device when it is created (usePendingBooking, localStorage keyed by room, one per room, pruned on expiry). On the room page, if a live hold exists for it (verified via the public booking endpoint), show a 'Resume payment' banner that reopens the invoice from the saved bolt11 and resumes the settlement poll. The saved entry is cleared when the booking confirms, the hold lapses, or it expires. Works without an account — the store is the browser's, not the server's. Verified headlessly against a real hold: the banner shows for a live hold and resumes to the invoice (QR + amount); an expired hold shows nothing and is pruned. vue-tsc build passes. Co-Authored-By: Claude Fable 5.1 --- .../chatelet/components/BookRoomDialog.vue | 16 ++- .../chatelet/composables/useBookingFlow.ts | 46 ++++++- .../chatelet/composables/useChatelet.ts | 9 +- .../chatelet/composables/usePendingBooking.ts | 119 ++++++++++++++++++ .../chatelet/views/ChateletDetailPage.vue | 85 ++++++++++++- 5 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 src/modules/chatelet/composables/usePendingBooking.ts diff --git a/src/modules/chatelet/components/BookRoomDialog.vue b/src/modules/chatelet/components/BookRoomDialog.vue index e928484..e65c307 100644 --- a/src/modules/chatelet/components/BookRoomDialog.vue +++ b/src/modules/chatelet/components/BookRoomDialog.vue @@ -28,6 +28,7 @@ import { import { useAuth } from '@/composables/useAuthService' import { formatSats } from '@/lib/utils/formatting' import { useBookingFlow } from '../composables/useBookingFlow' +import type { PendingBooking } from '../composables/usePendingBooking' import type { AvailabilityResult, Room } from '../types/room' interface Props { @@ -35,6 +36,11 @@ interface Props { /** The availability quote the guest just saw — dates + nights + price. */ availability: AvailabilityResult isOpen: boolean + /** + * When set, open straight to the invoice for a held booking saved on this + * device (the guest closed the dialog earlier) instead of the guest form. + */ + resume?: PendingBooking | null } interface Emits { @@ -161,11 +167,19 @@ watch( if (open) { flow.reset() confirmClose.value = false - form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } }) + if (props.resume) { + // Skip the guest form: rehydrate the saved invoice and poll. + void flow.resume(props.resume) + } else { + form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } }) + } } else { flow.reset() } }, + // The dialog is v-if-mounted with isOpen already true, so fire on mount — + // otherwise a resume never triggers flow.resume() and lands on the form. + { immediate: true }, ) diff --git a/src/modules/chatelet/composables/useBookingFlow.ts b/src/modules/chatelet/composables/useBookingFlow.ts index ea1d8af..cde533c 100644 --- a/src/modules/chatelet/composables/useBookingFlow.ts +++ b/src/modules/chatelet/composables/useBookingFlow.ts @@ -3,7 +3,8 @@ 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 { LOST_BOOKING_STATUSES, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking' +import { LOST_BOOKING_STATUSES, type Booking, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking' +import { usePendingBooking, type PendingBooking } from './usePendingBooking' const POLL_MS = 2000 @@ -18,6 +19,7 @@ export function useBookingFlow() { // DI, never a direct import (workspace rule). const api = tryInjectService(SERVICE_TOKENS.CHATELET_API) const paymentService = tryInjectService(SERVICE_TOKENS.PAYMENT_SERVICE) + const pending = usePendingBooking() const quote = ref(null) const qrCode = ref(null) @@ -44,6 +46,9 @@ export function useBookingFlow() { try { const q = await api.requestBooking(data) quote.value = q + // Persist the invoice so closing the dialog doesn't strand the hold: + // the guest can return and pay it (the public read-back has no bolt11). + pending.save(q) try { qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null } catch (err) { @@ -56,6 +61,42 @@ export function useBookingFlow() { } } + /** + * Re-open a held booking saved on this device (guest closed the dialog + * earlier). Rehydrates the invoice from local storage — no server round + * trip for the bolt11, which the public endpoint never returns — and + * resumes the settlement poll, which will surface a confirmed or lapsed + * hold on its first tick. + */ + async function resume(pb: PendingBooking): Promise { + const booking: Booking = { + id: pb.bookingId, + room_id: pb.roomId, + guest_pubkey: '', + guest_contact: null, + check_in: pb.checkIn, + check_out: pb.checkOut, + nights: pb.nights, + num_guests: pb.numGuests, + currency: pb.currency ?? '', + price_fiat: pb.quoteFiat ?? 0, + amount_sat: pb.amountSat, + deposit_sat: pb.depositSat, + status: 'awaiting_payment', + payment_hash: pb.paymentHash, + expires_at: pb.expiresAt, + created_at: pb.savedAt, + updated_at: pb.savedAt, + } + quote.value = { booking, payment_request: pb.paymentRequest, payment_hash: pb.paymentHash } + try { + qrCode.value = paymentService ? await paymentService.generateQRCode(pb.paymentRequest) : null + } catch (err) { + console.error('Error generating QR code:', err) + } + startPolling(pb.bookingId) + } + function startPolling(bookingId: string) { stopPolling() isPaymentPending.value = true @@ -67,9 +108,11 @@ export function useBookingFlow() { if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } } if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') { isPaid.value = true + pending.remove(b.room_id) stopPolling() } else if (LOST_BOOKING_STATUSES.has(b.status)) { lostStatus.value = b.status + pending.remove(b.room_id) stopPolling() } } catch (err) { @@ -142,6 +185,7 @@ export function useBookingFlow() { hasWalletWithBalance, isPayingWithWallet, request, + resume, copyInvoice, openExternalWallet, payWithWallet, diff --git a/src/modules/chatelet/composables/useChatelet.ts b/src/modules/chatelet/composables/useChatelet.ts index c93fc6d..b103392 100644 --- a/src/modules/chatelet/composables/useChatelet.ts +++ b/src/modules/chatelet/composables/useChatelet.ts @@ -2,6 +2,7 @@ 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 type { PublicBooking } from '../types/booking' import { useChateletStore } from '../stores/chatelet' export function useChatelet() { @@ -34,5 +35,11 @@ export function useChatelet() { return api.checkAvailability(roomId, checkIn, checkOut) } - return { rooms, isLoading, loadRooms, getRoom, checkAvailability } + /** Keyless read-back of a booking by id (status, dates, amounts). */ + async function getPublicBooking(bookingId: string): Promise { + if (!api) return null + return api.getPublicBooking(bookingId) + } + + return { rooms, isLoading, loadRooms, getRoom, checkAvailability, getPublicBooking } } diff --git a/src/modules/chatelet/composables/usePendingBooking.ts b/src/modules/chatelet/composables/usePendingBooking.ts new file mode 100644 index 0000000..879f115 --- /dev/null +++ b/src/modules/chatelet/composables/usePendingBooking.ts @@ -0,0 +1,119 @@ +import type { BookingQuote } from '../types/booking' + +const STORAGE_KEY = 'chatelet:pending-bookings' + +/** + * A held-but-unpaid booking kept on the device so the guest can return to + * its invoice. The public booking read-back strips the bolt11, so the only + * way back to the same invoice after the dialog is closed is to have saved + * it here. + */ +export interface PendingBooking { + bookingId: string + roomId: string + checkIn: string + checkOut: string + nights: number + numGuests: number + currency: string | null + quoteFiat: number | null + amountSat: number + depositSat: number + paymentRequest: string + paymentHash: string + /** ISO hold expiry, or null. Past-expiry entries are pruned on read. */ + expiresAt: string | null + savedAt: string +} + +type Store = Record + +function readStore(): Store { + try { + const raw = localStorage.getItem(STORAGE_KEY) + const parsed = raw ? JSON.parse(raw) : {} + return parsed && typeof parsed === 'object' ? (parsed as Store) : {} + } catch { + return {} + } +} + +function writeStore(store: Store): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + } catch { + // Private mode / quota / storage disabled — recovery is best-effort. + } +} + +function isLive(pb: PendingBooking): boolean { + if (!pb.expiresAt) return true + const t = new Date(pb.expiresAt).getTime() + return isNaN(t) || t > Date.now() +} + +/** Drop entries whose hold has already expired. Returns whether it changed. */ +function prune(store: Store): boolean { + let changed = false + for (const [roomId, pb] of Object.entries(store)) { + if (!isLive(pb)) { + delete store[roomId] + changed = true + } + } + return changed +} + +function fromQuote(q: BookingQuote): PendingBooking { + const b = q.booking + return { + bookingId: b.id, + roomId: b.room_id, + checkIn: b.check_in, + checkOut: b.check_out, + nights: b.nights, + numGuests: b.num_guests, + currency: b.currency, + quoteFiat: b.price_fiat, + amountSat: b.amount_sat, + depositSat: b.deposit_sat, + paymentRequest: q.payment_request, + paymentHash: q.payment_hash, + expiresAt: b.expires_at, + savedAt: new Date().toISOString(), + } +} + +/** + * Device-local record of held-but-unpaid bookings, one per room (a room + * holds at most one booking at a time). Lets a guest who closed the invoice + * dialog return and finish paying instead of waiting out the hold. Works + * without an account — the store is this browser's, not the server's. + */ +export function usePendingBooking() { + function save(q: BookingQuote): void { + if (!q.payment_request || !q.booking?.id) return + const store = readStore() + prune(store) + store[q.booking.room_id] = fromQuote(q) + writeStore(store) + } + + function getForRoom(roomId: string): PendingBooking | null { + if (!roomId) return null + const store = readStore() + if (prune(store)) writeStore(store) + return store[roomId] ?? null + } + + function remove(roomId: string): void { + if (!roomId) return + const store = readStore() + if (store[roomId]) { + delete store[roomId] + writeStore(store) + } + } + + return { save, getForRoom, remove } +} diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue index 612d425..fb816ba 100644 --- a/src/modules/chatelet/views/ChateletDetailPage.vue +++ b/src/modules/chatelet/views/ChateletDetailPage.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' import { watchDebounced } from '@vueuse/core' +import { format } from 'date-fns' import { Clock, MapPin, Moon, Users } from 'lucide-vue-next' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -12,7 +13,9 @@ import { formatSats } from '@/lib/utils/formatting' import { useAuth } from '@/composables/useAuthService' import { useChatelet } from '../composables/useChatelet' import { useRoomUnavailability } from '../composables/useRoomUnavailability' +import { usePendingBooking, type PendingBooking } from '../composables/usePendingBooking' import { stayIsFree } from '../lib/unavailable' +import { LOST_BOOKING_STATUSES } from '../types/booking' import BookRoomDialog from '../components/BookRoomDialog.vue' import StayDatePicker from '../components/StayDatePicker.vue' import type { AvailabilityResult, Room } from '../types/room' @@ -20,7 +23,8 @@ import type { AvailabilityResult, Room } from '../types/room' const route = useRoute() const router = useRouter() const { isAuthenticated } = useAuth() -const { getRoom, checkAvailability } = useChatelet() +const { getRoom, checkAvailability, getPublicBooking } = useChatelet() +const pendingStore = usePendingBooking() const room = ref(null) const loading = ref(true) @@ -32,6 +36,11 @@ 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(null) +/** A held-but-unpaid booking saved on this device, if the guest closed the + * invoice earlier — surfaced as a "resume" banner. */ +const pendingBooking = ref(null) +/** Passed to the dialog to reopen the saved invoice instead of the form. */ +const resumePayload = ref(null) // Occupied nights for the calendar; refreshed after a booking and when a // quote contradicts the local set (someone else's hold appeared). @@ -68,6 +77,59 @@ onMounted(async () => { checkIn.value = qi checkOut.value = qo } + await refreshPending() +}) + +/** + * Surface a "resume payment" banner if this device holds an unpaid booking + * for this room. Verify server-side first: drop it if it already confirmed + * or the hold lapsed (the store also self-prunes past-expiry entries). + */ +async function refreshPending() { + const id = roomIdRef.value + if (!id) return + const pb = pendingStore.getForRoom(id) + if (!pb) { + pendingBooking.value = null + return + } + try { + const live = await getPublicBooking(pb.bookingId) + const status = live?.status + if (!status || status === 'confirmed' || status === 'checked_in' || status === 'completed' || LOST_BOOKING_STATUSES.has(status)) { + pendingStore.remove(id) + pendingBooking.value = null + return + } + } catch { + // Network hiccup — keep the local record and let the resumed poll decide. + } + pendingBooking.value = pb +} + +function resumeBooking() { + const pb = pendingBooking.value + if (!pb || !room.value) return + // Rebuild the availability shape the dialog header reads from the saved hold. + bookingQuote.value = { + room_id: pb.roomId, + check_in: pb.checkIn, + check_out: pb.checkOut, + available: true, + nights: pb.nights, + quote_sat: pb.amountSat, + quote_fiat: pb.quoteFiat, + currency: pb.currency, + } + resumePayload.value = pb + showBooking.value = true +} + +const pendingStayLabel = computed(() => { + const pb = pendingBooking.value + if (!pb) return '' + const fmt = (ymd: string) => format(new Date(ymd + 'T00:00:00'), 'MMM d') + return `${fmt(pb.checkIn)} → ${fmt(pb.checkOut)}` }) async function onCheck() { @@ -116,11 +178,17 @@ function onBooked() { result.value = null checkIn.value = '' checkOut.value = '' + pendingBooking.value = null void unavailability.refresh() } watch(showBooking, (open) => { - if (!open) bookingQuote.value = null + if (!open) { + bookingQuote.value = null + resumePayload.value = null + // The hold may have been paid or lapsed while the dialog was open. + void refreshPending() + } }) @@ -198,6 +266,18 @@ watch(showBooking, (open) => { + + +
+

You have an unpaid hold

+

+ {{ pendingStayLabel }} · {{ formatSats(pendingBooking.depositSat) }} +

+
+ +
+
+ { v-if="room && bookingQuote" :room="room" :availability="bookingQuote" + :resume="resumePayload" v-model:is-open="showBooking" @booked="onBooked" /> -- 2.55.0