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" />