From 718d6e76e19c9eebfc0e9162fee762347f351849 Mon Sep 17 00:00:00 2001
From: Padreug
Date: Tue, 15 Sep 2026 23:10:26 +0200
Subject: [PATCH 1/4] feat(auth): send the user back to ?redirect= after login
Both login pages always landed on '/'. A page that needs an identity
(chatelet's Book button) can now bounce an anonymous user through
/login?redirect= and get them back. Only same-origin absolute
paths are honoured; anything else falls back to home.
Co-Authored-By: Claude Fable 5.1
---
src/lib/router-helpers.ts | 13 +++++++++++++
src/pages/Login.vue | 8 +++++---
src/pages/LoginDemo.vue | 12 +++++++-----
3 files changed, 25 insertions(+), 8 deletions(-)
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/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 @@
+
+
+
+
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.
+
+
--
2.55.0
From 56e1d7a7804034dd4f89650c68541d8d4bdf153d Mon Sep 17 00:00:00 2001
From: Padreug
Date: Tue, 15 Sep 2026 23:44:13 +0200
Subject: [PATCH 4/4] feat(chatelet): poll the booking itself, not the invoice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Switch the invoice screen's poll from LNbits core's payment status to
chatelet's new keyless GET /api/v1/public/bookings/{id} (chatelet#18).
The dialog now waits for the booking to reach confirmed — meaning the
extension's paid listener has actually run — and shows a 'Dates
released' screen when the hold expires or is declined before payment
instead of spinning forever.
Needs chatelet >= the build that ships the endpoint on the target
instance (staging must be upgraded before this reaches dev).
Co-Authored-By: Claude Fable 5.1
---
.../chatelet/components/BookRoomDialog.vue | 25 +++++++++++++++--
.../chatelet/composables/useBookingFlow.ts | 27 ++++++++++++-------
.../chatelet/services/ChateletApiService.ts | 18 ++++++-------
src/modules/chatelet/types/booking.ts | 23 ++++++++++------
4 files changed, 63 insertions(+), 30 deletions(-)
diff --git a/src/modules/chatelet/components/BookRoomDialog.vue b/src/modules/chatelet/components/BookRoomDialog.vue
index eccdb5d..61381ff 100644
--- a/src/modules/chatelet/components/BookRoomDialog.vue
+++ b/src/modules/chatelet/components/BookRoomDialog.vue
@@ -5,7 +5,7 @@ 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 { Check, CheckCircle2, Copy, Loader2, TimerOff, Wallet, Zap } from 'lucide-vue-next'
import {
Dialog,
DialogContent,
@@ -112,12 +112,19 @@ const holdExpiry = computed(() => {
return isNaN(d.getTime()) ? '' : format(d, 'p')
})
-const step = computed<'form' | 'invoice' | 'confirmed'>(() => {
+const step = computed<'form' | 'invoice' | 'confirmed' | 'lost'>(() => {
if (flow.isPaid.value) return 'confirmed'
+ if (flow.lostStatus.value) return 'lost'
if (flow.quote.value) return 'invoice'
return 'form'
})
+const lostLabel = computed(() =>
+ flow.lostStatus.value === 'expired'
+ ? 'The hold on your dates expired before the invoice was paid.'
+ : 'The host released these dates before the invoice was paid.',
+)
+
watch(
() => flow.isPaid.value,
(paid) => {
@@ -303,6 +310,20 @@ watch(
+
+
+
+
+
Dates released
+
{{ lostLabel }}
+
+
+ If you already paid, the host will see it — otherwise check the dates
+ again and start over.
+
+
+
+
diff --git a/src/modules/chatelet/composables/useBookingFlow.ts b/src/modules/chatelet/composables/useBookingFlow.ts
index 26e49fa..ea1d8af 100644
--- a/src/modules/chatelet/composables/useBookingFlow.ts
+++ b/src/modules/chatelet/composables/useBookingFlow.ts
@@ -3,7 +3,7 @@ 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'
+import { LOST_BOOKING_STATUSES, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking'
const POLL_MS = 2000
@@ -11,10 +11,8 @@ 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.
+ * LNbits wallet) and polls the booking until the extension's paid-invoice
+ * listener flips it to `confirmed` — or the hold lapses (`expired`).
*/
export function useBookingFlow() {
// DI, never a direct import (workspace rule).
@@ -26,6 +24,8 @@ export function useBookingFlow() {
const isRequesting = ref(false)
const isPaymentPending = ref(false)
const isPaid = ref(false)
+ /** Set when the hold is gone (expired / declined / cancelled) before payment. */
+ const lostStatus = ref(null)
const copiedInvoice = ref(false)
let pollTimer: ReturnType | null = null
@@ -49,27 +49,32 @@ export function useBookingFlow() {
} catch (err) {
console.error('Error generating QR code:', err)
}
- startPolling(q.payment_hash)
+ startPolling(q.booking.id)
return q
} finally {
isRequesting.value = false
}
}
- function startPolling(hash: string) {
+ function startPolling(bookingId: string) {
stopPolling()
isPaymentPending.value = true
const check = async () => {
try {
- const status = await api?.getPaymentStatus(hash)
- if (status?.paid) {
+ const b = await api?.getPublicBooking(bookingId)
+ if (!b) return
+ 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
stopPolling()
+ } else if (LOST_BOOKING_STATUSES.has(b.status)) {
+ lostStatus.value = b.status
+ stopPolling()
}
} catch (err) {
// Transient network errors just mean "try again next tick".
- console.error('Error checking booking payment status:', err)
+ console.error('Error checking booking status:', err)
}
}
@@ -117,6 +122,7 @@ export function useBookingFlow() {
quote.value = null
qrCode.value = null
isPaid.value = false
+ lostStatus.value = null
copiedInvoice.value = false
}
@@ -130,6 +136,7 @@ export function useBookingFlow() {
isRequesting,
isPaymentPending,
isPaid,
+ lostStatus,
copiedInvoice,
userWallets,
hasWalletWithBalance,
diff --git a/src/modules/chatelet/services/ChateletApiService.ts b/src/modules/chatelet/services/ChateletApiService.ts
index c468c47..2be1400 100644
--- a/src/modules/chatelet/services/ChateletApiService.ts
+++ b/src/modules/chatelet/services/ChateletApiService.ts
@@ -1,5 +1,5 @@
import type { AvailabilityResult, Room } from '../types/room'
-import type { BookingQuote, BookingRequest, PaymentStatus } from '../types/booking'
+import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking'
export interface ChateletApiConfig {
baseUrl: string
@@ -10,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. Guest browsing, availability and the booking request are all
- * public endpoints; settlement is polled on the invoice via LNbits core.
+ * BaseService. Guest browsing, availability, the booking request and the
+ * booking read-back are all keyless public endpoints.
*
* Transport note: this is HTTP-for-now. The extension also exposes a
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
@@ -56,14 +56,12 @@ export class ChateletApiService {
}
/**
- * 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`.
+ * Keyless read-back of the guest's own booking — what the invoice screen
+ * polls. `confirmed` means the extension's paid-invoice listener has
+ * run; `expired` / `declined` mean the hold is gone.
*/
- async getPaymentStatus(paymentHash: string): Promise {
- return this.request(`/api/v1/payments/${paymentHash}`, { method: 'GET' })
+ async getPublicBooking(bookingId: string): Promise {
+ return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' })
}
private async request(path: string, init: RequestInit = {}): Promise {
diff --git a/src/modules/chatelet/types/booking.ts b/src/modules/chatelet/types/booking.ts
index 8a843a2..83c0831 100644
--- a/src/modules/chatelet/types/booking.ts
+++ b/src/modules/chatelet/types/booking.ts
@@ -60,12 +60,19 @@ export interface BookingQuote {
}
/**
- * 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.
+ * Keyless read-back of the guest's own booking
+ * (`GET /chatelet/api/v1/public/bookings/{id}`, chatelet ≥ v0.4.0): the
+ * Booking minus the guest's pubkey/contact and the Lightning/Nostr plumbing.
+ * The booking id from the quote is the capability.
*/
-export interface PaymentStatus {
- paid: boolean
- status?: string
-}
+export type PublicBooking = Omit<
+ Booking,
+ 'guest_pubkey' | 'guest_contact' | 'payment_hash'
+>
+
+/** Statuses that mean the hold is gone and the invoice will never confirm. */
+export const LOST_BOOKING_STATUSES: ReadonlySet = new Set([
+ 'expired',
+ 'declined',
+ 'cancelled',
+])
--
2.55.0