feat(chatelet): let a guest resume a held-but-unpaid booking #160
5 changed files with 270 additions and 5 deletions
feat(chatelet): let a guest resume a held-but-unpaid booking
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 <noreply@anthropic.com>
commit
8d9a31ca1c
|
|
@ -28,6 +28,7 @@ import {
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { formatSats } from '@/lib/utils/formatting'
|
import { formatSats } from '@/lib/utils/formatting'
|
||||||
import { useBookingFlow } from '../composables/useBookingFlow'
|
import { useBookingFlow } from '../composables/useBookingFlow'
|
||||||
|
import type { PendingBooking } from '../composables/usePendingBooking'
|
||||||
import type { AvailabilityResult, Room } from '../types/room'
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -35,6 +36,11 @@ interface Props {
|
||||||
/** The availability quote the guest just saw — dates + nights + price. */
|
/** The availability quote the guest just saw — dates + nights + price. */
|
||||||
availability: AvailabilityResult
|
availability: AvailabilityResult
|
||||||
isOpen: boolean
|
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 {
|
interface Emits {
|
||||||
|
|
@ -161,11 +167,19 @@ watch(
|
||||||
if (open) {
|
if (open) {
|
||||||
flow.reset()
|
flow.reset()
|
||||||
confirmClose.value = false
|
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 {
|
} else {
|
||||||
flow.reset()
|
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 },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { toast } from 'vue-sonner'
|
||||||
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
||||||
import type { PaymentService } from '@/core/services/PaymentService'
|
import type { PaymentService } from '@/core/services/PaymentService'
|
||||||
import type { ChateletApiService } from '../services/ChateletApiService'
|
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
|
const POLL_MS = 2000
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ export function useBookingFlow() {
|
||||||
// DI, never a direct import (workspace rule).
|
// DI, never a direct import (workspace rule).
|
||||||
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
||||||
const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE)
|
const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE)
|
||||||
|
const pending = usePendingBooking()
|
||||||
|
|
||||||
const quote = ref<BookingQuote | null>(null)
|
const quote = ref<BookingQuote | null>(null)
|
||||||
const qrCode = ref<string | null>(null)
|
const qrCode = ref<string | null>(null)
|
||||||
|
|
@ -44,6 +46,9 @@ export function useBookingFlow() {
|
||||||
try {
|
try {
|
||||||
const q = await api.requestBooking(data)
|
const q = await api.requestBooking(data)
|
||||||
quote.value = q
|
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 {
|
try {
|
||||||
qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null
|
qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null
|
||||||
} catch (err) {
|
} 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<void> {
|
||||||
|
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) {
|
function startPolling(bookingId: string) {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
isPaymentPending.value = true
|
isPaymentPending.value = true
|
||||||
|
|
@ -67,9 +108,11 @@ export function useBookingFlow() {
|
||||||
if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } }
|
if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } }
|
||||||
if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') {
|
if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') {
|
||||||
isPaid.value = true
|
isPaid.value = true
|
||||||
|
pending.remove(b.room_id)
|
||||||
stopPolling()
|
stopPolling()
|
||||||
} else if (LOST_BOOKING_STATUSES.has(b.status)) {
|
} else if (LOST_BOOKING_STATUSES.has(b.status)) {
|
||||||
lostStatus.value = b.status
|
lostStatus.value = b.status
|
||||||
|
pending.remove(b.room_id)
|
||||||
stopPolling()
|
stopPolling()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -142,6 +185,7 @@ export function useBookingFlow() {
|
||||||
hasWalletWithBalance,
|
hasWalletWithBalance,
|
||||||
isPayingWithWallet,
|
isPayingWithWallet,
|
||||||
request,
|
request,
|
||||||
|
resume,
|
||||||
copyInvoice,
|
copyInvoice,
|
||||||
openExternalWallet,
|
openExternalWallet,
|
||||||
payWithWallet,
|
payWithWallet,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { storeToRefs } from 'pinia'
|
||||||
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
||||||
import type { ChateletApiService } from '../services/ChateletApiService'
|
import type { ChateletApiService } from '../services/ChateletApiService'
|
||||||
import type { AvailabilityResult, Room } from '../types/room'
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
import type { PublicBooking } from '../types/booking'
|
||||||
import { useChateletStore } from '../stores/chatelet'
|
import { useChateletStore } from '../stores/chatelet'
|
||||||
|
|
||||||
export function useChatelet() {
|
export function useChatelet() {
|
||||||
|
|
@ -34,5 +35,11 @@ export function useChatelet() {
|
||||||
return api.checkAvailability(roomId, checkIn, checkOut)
|
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<PublicBooking | null> {
|
||||||
|
if (!api) return null
|
||||||
|
return api.getPublicBooking(bookingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rooms, isLoading, loadRooms, getRoom, checkAvailability, getPublicBooking }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
119
src/modules/chatelet/composables/usePendingBooking.ts
Normal file
119
src/modules/chatelet/composables/usePendingBooking.ts
Normal file
|
|
@ -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<string, PendingBooking>
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import { Clock, MapPin, Moon, Users } from 'lucide-vue-next'
|
import { Clock, MapPin, Moon, Users } from 'lucide-vue-next'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -12,7 +13,9 @@ import { formatSats } from '@/lib/utils/formatting'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useChatelet } from '../composables/useChatelet'
|
import { useChatelet } from '../composables/useChatelet'
|
||||||
import { useRoomUnavailability } from '../composables/useRoomUnavailability'
|
import { useRoomUnavailability } from '../composables/useRoomUnavailability'
|
||||||
|
import { usePendingBooking, type PendingBooking } from '../composables/usePendingBooking'
|
||||||
import { stayIsFree } from '../lib/unavailable'
|
import { stayIsFree } from '../lib/unavailable'
|
||||||
|
import { LOST_BOOKING_STATUSES } from '../types/booking'
|
||||||
import BookRoomDialog from '../components/BookRoomDialog.vue'
|
import BookRoomDialog from '../components/BookRoomDialog.vue'
|
||||||
import StayDatePicker from '../components/StayDatePicker.vue'
|
import StayDatePicker from '../components/StayDatePicker.vue'
|
||||||
import type { AvailabilityResult, Room } from '../types/room'
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
|
@ -20,7 +23,8 @@ import type { AvailabilityResult, Room } from '../types/room'
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const { getRoom, checkAvailability } = useChatelet()
|
const { getRoom, checkAvailability, getPublicBooking } = useChatelet()
|
||||||
|
const pendingStore = usePendingBooking()
|
||||||
|
|
||||||
const room = ref<Room | null>(null)
|
const room = ref<Room | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
@ -32,6 +36,11 @@ const showBooking = ref(false)
|
||||||
/** Quote snapshot the dialog books against — survives the page's own
|
/** Quote snapshot the dialog books against — survives the page's own
|
||||||
* `result` being cleared once the dates are taken. */
|
* `result` being cleared once the dates are taken. */
|
||||||
const bookingQuote = ref<AvailabilityResult | null>(null)
|
const bookingQuote = ref<AvailabilityResult | null>(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<PendingBooking | null>(null)
|
||||||
|
/** Passed to the dialog to reopen the saved invoice instead of the form. */
|
||||||
|
const resumePayload = ref<PendingBooking | null>(null)
|
||||||
|
|
||||||
// Occupied nights for the calendar; refreshed after a booking and when a
|
// Occupied nights for the calendar; refreshed after a booking and when a
|
||||||
// quote contradicts the local set (someone else's hold appeared).
|
// quote contradicts the local set (someone else's hold appeared).
|
||||||
|
|
@ -68,6 +77,59 @@ onMounted(async () => {
|
||||||
checkIn.value = qi
|
checkIn.value = qi
|
||||||
checkOut.value = qo
|
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() {
|
async function onCheck() {
|
||||||
|
|
@ -116,11 +178,17 @@ function onBooked() {
|
||||||
result.value = null
|
result.value = null
|
||||||
checkIn.value = ''
|
checkIn.value = ''
|
||||||
checkOut.value = ''
|
checkOut.value = ''
|
||||||
|
pendingBooking.value = null
|
||||||
void unavailability.refresh()
|
void unavailability.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(showBooking, (open) => {
|
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()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -198,6 +266,18 @@ watch(showBooking, (open) => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="pendingBooking && !showBooking" class="border-primary/40 bg-primary/5">
|
||||||
|
<CardContent class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<p class="text-sm font-medium text-foreground">You have an unpaid hold</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ pendingStayLabel }} · {{ formatSats(pendingBooking.depositSat) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" class="shrink-0" @click="resumeBooking">Resume payment</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="p-4 space-y-4">
|
<CardContent class="p-4 space-y-4">
|
||||||
<StayDatePicker
|
<StayDatePicker
|
||||||
|
|
@ -243,6 +323,7 @@ watch(showBooking, (open) => {
|
||||||
v-if="room && bookingQuote"
|
v-if="room && bookingQuote"
|
||||||
:room="room"
|
:room="room"
|
||||||
:availability="bookingQuote"
|
:availability="bookingQuote"
|
||||||
|
:resume="resumePayload"
|
||||||
v-model:is-open="showBooking"
|
v-model:is-open="showBooking"
|
||||||
@booked="onBooked"
|
@booked="onBooked"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue