From c369beab924582355b540a10f578b3179bcdc9ed Mon Sep 17 00:00:00 2001
From: Padreug
Date: Tue, 15 Sep 2026 23:19:05 +0200
Subject: [PATCH] =?UTF-8?q?feat(chatelet):=20book=20a=20room=20=E2=80=94?=
=?UTF-8?q?=20hold,=20Lightning=20invoice,=20paid=20confirmation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../chatelet/components/BookRoomDialog.vue | 332 ++++++++++++++++++
.../chatelet/composables/useBookingFlow.ts | 143 ++++++++
.../chatelet/views/ChateletDetailPage.vue | 62 +++-
3 files changed, 535 insertions(+), 2 deletions(-)
create mode 100644 src/modules/chatelet/components/BookRoomDialog.vue
create mode 100644 src/modules/chatelet/composables/useBookingFlow.ts
diff --git a/src/modules/chatelet/components/BookRoomDialog.vue b/src/modules/chatelet/components/BookRoomDialog.vue
new file mode 100644
index 0000000..eccdb5d
--- /dev/null
+++ b/src/modules/chatelet/components/BookRoomDialog.vue
@@ -0,0 +1,332 @@
+
+
+
+
+
diff --git a/src/modules/chatelet/composables/useBookingFlow.ts b/src/modules/chatelet/composables/useBookingFlow.ts
new file mode 100644
index 0000000..26e49fa
--- /dev/null
+++ b/src/modules/chatelet/composables/useBookingFlow.ts
@@ -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(SERVICE_TOKENS.CHATELET_API)
+ const paymentService = tryInjectService(SERVICE_TOKENS.PAYMENT_SERVICE)
+
+ const quote = ref(null)
+ const qrCode = ref(null)
+ const isRequesting = ref(false)
+ const isPaymentPending = ref(false)
+ const isPaid = ref(false)
+ const copiedInvoice = ref(false)
+
+ let pollTimer: ReturnType | 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 {
+ 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,
+ }
+}
diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue
index 2000bdb..ae43195 100644
--- a/src/modules/chatelet/views/ChateletDetailPage.vue
+++ b/src/modules/chatelet/views/ChateletDetailPage.vue
@@ -1,6 +1,6 @@
@@ -138,12 +186,22 @@ async function onCheck() {
(≈ {{ result.quote_fiat }} {{ result.currency }})
-
+
Not available for those dates.
+
+