diff --git a/src/lib/router-helpers.ts b/src/lib/router-helpers.ts
index f80f4ab..84bec63 100644
--- a/src/lib/router-helpers.ts
+++ b/src/lib/router-helpers.ts
@@ -77,3 +77,16 @@ export const catchAllRoute: RouteRecordRaw = {
path: '/:pathMatch(.*)*',
redirect: '/',
}
+
+/**
+ * Where to send the user after a successful login. Honours a `?redirect=`
+ * query param so a page can bounce an anonymous user through /login and
+ * back (e.g. chatelet's Book button). Only same-origin absolute paths are
+ * accepted — anything else (external URLs, protocol-relative `//host`)
+ * falls back to home.
+ */
+export function postLoginTarget(redirect: unknown): string {
+ if (typeof redirect !== 'string') return '/'
+ if (!redirect.startsWith('/') || redirect.startsWith('//')) return '/'
+ return redirect
+}
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/index.ts b/src/modules/chatelet/index.ts
index 60653c9..8980b69 100644
--- a/src/modules/chatelet/index.ts
+++ b/src/modules/chatelet/index.ts
@@ -8,7 +8,7 @@ export interface ChateletModuleConfig {
/**
* Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals
- * extension. Slice 1: read-only discovery + availability over HTTP.
+ * extension: discovery, availability and Lightning-paid booking over HTTP.
*/
export const chateletModule = createModulePlugin({
name: 'chatelet',
@@ -52,3 +52,4 @@ export const chateletModule = createModulePlugin({
export default chateletModule
export type { Room, AvailabilityResult } from './types/room'
+export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking'
diff --git a/src/modules/chatelet/services/ChateletApiService.ts b/src/modules/chatelet/services/ChateletApiService.ts
index 015252a..c468c47 100644
--- a/src/modules/chatelet/services/ChateletApiService.ts
+++ b/src/modules/chatelet/services/ChateletApiService.ts
@@ -1,4 +1,5 @@
import type { AvailabilityResult, Room } from '../types/room'
+import type { BookingQuote, BookingRequest, PaymentStatus } from '../types/booking'
export interface ChateletApiConfig {
baseUrl: string
@@ -9,8 +10,8 @@ export interface ChateletApiConfig {
* 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.
+ * BaseService. Guest browsing, availability and the booking request are all
+ * public endpoints; settlement is polled on the invoice via LNbits core.
*
* Transport note: this is HTTP-for-now. The extension also exposes a
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
@@ -41,6 +42,30 @@ export class ChateletApiService {
})
}
+ /**
+ * Hold the dates and get the invoice that confirms them. The backend
+ * quotes the amount (canonical `booking.deposit_sat`) — the guest never
+ * recomputes what they owe. 502 = invoice creation failed server-side.
+ */
+ async requestBooking(data: BookingRequest): Promise {
+ return this.request('/chatelet/api/v1/bookings', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+ }
+
+ /**
+ * Anonymous settlement check on LNbits core. Chatelet's own
+ * `GET /bookings/{id}` needs a wallet key, so until a public booking
+ * status endpoint lands the guest polls the invoice instead — the
+ * extension's paid-invoice listener is what flips the booking to
+ * `confirmed`.
+ */
+ async getPaymentStatus(paymentHash: string): Promise {
+ return this.request(`/api/v1/payments/${paymentHash}`, { method: 'GET' })
+ }
+
private async request(path: string, init: RequestInit = {}): Promise {
const headers: Record = {
accept: 'application/json',
diff --git a/src/modules/chatelet/types/booking.ts b/src/modules/chatelet/types/booking.ts
new file mode 100644
index 0000000..8a843a2
--- /dev/null
+++ b/src/modules/chatelet/types/booking.ts
@@ -0,0 +1,71 @@
+/**
+ * Booking wire types — mirror `models.py` in the LNbits `chatelet`
+ * extension (BookingRequestData / Booking / BookingQuote). Field names are
+ * kept as the backend spells them so the JSON maps 1:1.
+ */
+
+export type BookingStatus =
+ | 'held'
+ | 'awaiting_payment'
+ | 'confirmed'
+ | 'declined'
+ | 'cancelled'
+ | 'expired'
+ | (string & {})
+
+/** What a guest sends to `POST /chatelet/api/v1/bookings`. */
+export interface BookingRequest {
+ room_id: string
+ /** Hex pubkey of the guest — the identity the check-in DM goes to. */
+ guest_pubkey: string
+ /** YYYY-MM-DD, inclusive. */
+ check_in: string
+ /** YYYY-MM-DD, exclusive. */
+ check_out: string
+ num_guests: number
+ guest_contact?: string
+ message?: string
+}
+
+export interface Booking {
+ id: string
+ room_id: string
+ guest_pubkey: string
+ guest_contact: string | null
+ check_in: string
+ check_out: string
+ nights: number
+ num_guests: number
+ /** Snapshot of the room's price currency. */
+ currency: string
+ /** nights × room price, display only. */
+ price_fiat: number
+ /** Canonical total due, in sats. */
+ amount_sat: number
+ /** Portion invoiced now (== amount_sat at 100 % prepay). */
+ deposit_sat: number
+ status: BookingStatus
+ payment_hash: string | null
+ /** Hold expiry while held / awaiting_payment; null once confirmed. */
+ expires_at: string | null
+ created_at: string
+ updated_at: string
+}
+
+/** Response to a booking request: the held booking + the bolt11 that confirms it. */
+export interface BookingQuote {
+ booking: Booking
+ payment_request: string
+ payment_hash: string
+}
+
+/**
+ * Anonymous read of LNbits core `GET /api/v1/payments/{hash}` — the guest
+ * cannot read their booking over HTTP yet (that endpoint needs a wallet
+ * key), so settlement is polled on the invoice instead. `status` is only
+ * present on a failed payment.
+ */
+export interface PaymentStatus {
+ paid: boolean
+ status?: string
+}
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.
+
+
diff --git a/src/pages/Login.vue b/src/pages/Login.vue
index ff89413..7a440c7 100644
--- a/src/pages/Login.vue
+++ b/src/pages/Login.vue
@@ -111,7 +111,8 @@