Compare commits
No commits in common. "c369beab924582355b540a10f578b3179bcdc9ed" and "3eb45b04a76726fe4cc88725197277a0a401c383" have entirely different histories.
c369beab92
...
3eb45b04a7
9 changed files with 13 additions and 660 deletions
|
|
@ -77,16 +77,3 @@ export const catchAllRoute: RouteRecordRaw = {
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
redirect: '/',
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
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 {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/form'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { formatSats } from '@/lib/utils/formatting'
|
|
||||||
import { useBookingFlow } from '../composables/useBookingFlow'
|
|
||||||
import type { AvailabilityResult, Room } from '../types/room'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
room: Room
|
|
||||||
/** The availability quote the guest just saw — dates + nights + price. */
|
|
||||||
availability: AvailabilityResult
|
|
||||||
isOpen: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:isOpen', value: boolean): void
|
|
||||||
/** Fired once when the invoice settles, before the dialog closes. */
|
|
||||||
(e: 'booked', bookingId: string): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const { currentUser } = useAuth()
|
|
||||||
const flow = useBookingFlow()
|
|
||||||
|
|
||||||
// --- Guest details form --------------------------------------------------
|
|
||||||
|
|
||||||
const formSchema = computed(() =>
|
|
||||||
toTypedSchema(
|
|
||||||
z.object({
|
|
||||||
num_guests: z.coerce
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1, 'At least one guest')
|
|
||||||
.max(props.room.max_guests, `This room sleeps up to ${props.room.max_guests}`),
|
|
||||||
guest_contact: z.string().max(200, 'Too long').optional(),
|
|
||||||
message: z.string().max(1000, 'Too long').optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: formSchema,
|
|
||||||
initialValues: { num_guests: 1, guest_contact: '', message: '' },
|
|
||||||
})
|
|
||||||
const isFormValid = computed(() => form.meta.value.valid)
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
|
||||||
const pubkey = currentUser.value?.pubkey
|
|
||||||
if (!pubkey) {
|
|
||||||
toast.error('Sign in to book a room')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await flow.request({
|
|
||||||
room_id: props.room.id,
|
|
||||||
guest_pubkey: pubkey,
|
|
||||||
check_in: props.availability.check_in,
|
|
||||||
check_out: props.availability.check_out,
|
|
||||||
num_guests: values.num_guests,
|
|
||||||
guest_contact: values.guest_contact?.trim() || undefined,
|
|
||||||
message: values.message?.trim() || undefined,
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
|
||||||
toast.error(e instanceof Error ? e.message : 'Could not request the booking')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Display helpers -----------------------------------------------------
|
|
||||||
|
|
||||||
function longDate(ymd: string): string {
|
|
||||||
return format(new Date(ymd + 'T00:00:00'), 'EEE, MMM d')
|
|
||||||
}
|
|
||||||
|
|
||||||
const stayLabel = computed(
|
|
||||||
() => `${longDate(props.availability.check_in)} → ${longDate(props.availability.check_out)}`,
|
|
||||||
)
|
|
||||||
const nightsLabel = computed(
|
|
||||||
() => `${props.availability.nights} night${props.availability.nights === 1 ? '' : 's'}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Hold expiry as a wall-clock time, so the guest knows how long the invoice lives. */
|
|
||||||
const holdExpiry = computed(() => {
|
|
||||||
const iso = flow.booking.value?.expires_at
|
|
||||||
if (!iso) return ''
|
|
||||||
const d = new Date(iso)
|
|
||||||
return isNaN(d.getTime()) ? '' : format(d, 'p')
|
|
||||||
})
|
|
||||||
|
|
||||||
const step = computed<'form' | 'invoice' | 'confirmed'>(() => {
|
|
||||||
if (flow.isPaid.value) return 'confirmed'
|
|
||||||
if (flow.quote.value) return 'invoice'
|
|
||||||
return 'form'
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => flow.isPaid.value,
|
|
||||||
(paid) => {
|
|
||||||
if (paid && flow.booking.value) emit('booked', flow.booking.value.id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Open / close --------------------------------------------------------
|
|
||||||
|
|
||||||
const confirmClose = ref(false)
|
|
||||||
|
|
||||||
function handleClose(open: boolean) {
|
|
||||||
if (open) return
|
|
||||||
// Closing mid-invoice abandons the hold; it expires on its own, but
|
|
||||||
// make the guest confirm so a stray tap doesn't lose the QR.
|
|
||||||
if (step.value === 'invoice' && !confirmClose.value) {
|
|
||||||
confirmClose.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
confirmClose.value = false
|
|
||||||
emit('update:isOpen', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.isOpen,
|
|
||||||
(open) => {
|
|
||||||
if (open) {
|
|
||||||
flow.reset()
|
|
||||||
confirmClose.value = false
|
|
||||||
form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } })
|
|
||||||
} else {
|
|
||||||
flow.reset()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog :open="isOpen" @update:open="handleClose">
|
|
||||||
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{{ room.title }}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{{ stayLabel }} · {{ nightsLabel }}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<!-- Step 1: who's coming -->
|
|
||||||
<form v-if="step === 'form'" class="space-y-4" @submit="onSubmit">
|
|
||||||
<div class="rounded-lg bg-muted/50 p-3 text-sm flex items-baseline justify-between">
|
|
||||||
<span class="text-muted-foreground">Total</span>
|
|
||||||
<span class="font-semibold text-foreground">
|
|
||||||
<template v-if="availability.quote_sat != null">
|
|
||||||
{{ formatSats(availability.quote_sat) }}
|
|
||||||
</template>
|
|
||||||
<span
|
|
||||||
v-if="availability.quote_fiat != null"
|
|
||||||
class="text-muted-foreground font-normal"
|
|
||||||
>
|
|
||||||
(≈ {{ availability.quote_fiat }} {{ availability.currency }})
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="num_guests">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Guests</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="number" min="1" :max="room.max_guests" v-bind="componentField" />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Sleeps up to {{ room.max_guests }}</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="guest_contact">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Contact
|
|
||||||
<span class="text-muted-foreground font-normal">(optional)</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Email or phone, in case the host needs to reach you" v-bind="componentField" />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="message">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Note to the host
|
|
||||||
<span class="text-muted-foreground font-normal">(optional)</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea rows="3" placeholder="Arrival time, questions…" v-bind="componentField" />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Button type="submit" class="w-full" :disabled="!isFormValid || flow.isRequesting.value">
|
|
||||||
<Loader2 v-if="flow.isRequesting.value" class="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{{ flow.isRequesting.value ? 'Holding your dates…' : 'Continue to payment' }}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Step 2: pay the invoice (events / restaurant invoice-card pattern) -->
|
|
||||||
<div v-else-if="step === 'invoice'" class="py-2 space-y-4">
|
|
||||||
<div class="text-center space-y-1">
|
|
||||||
<h3 class="text-lg font-semibold">Pay to confirm</h3>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
<template v-if="flow.hasWalletWithBalance.value">
|
|
||||||
Scan with any Lightning wallet, or pay from your LNbits wallet below.
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
Scan with any Lightning wallet, or open it in your wallet app.
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
|
||||||
<!-- White background regardless of theme so phone cameras parse it. -->
|
|
||||||
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
|
|
||||||
<img
|
|
||||||
v-if="flow.qrCode.value"
|
|
||||||
:src="flow.qrCode.value"
|
|
||||||
alt="Lightning payment QR code"
|
|
||||||
class="block h-56 w-56 sm:h-64 sm:w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-baseline justify-between">
|
|
||||||
<span class="text-xs text-muted-foreground">Amount</span>
|
|
||||||
<span class="font-mono text-sm font-semibold text-primary">
|
|
||||||
{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" class="flex-1 font-mono text-xs" @click="flow.copyInvoice">
|
|
||||||
<Check v-if="flow.copiedInvoice.value" class="mr-2 h-3.5 w-3.5" />
|
|
||||||
<Copy v-else class="mr-2 h-3.5 w-3.5" />
|
|
||||||
{{ flow.copiedInvoice.value ? 'Copied' : 'Copy' }}
|
|
||||||
</Button>
|
|
||||||
<Button variant="default" size="sm" class="flex-1 text-xs" @click="flow.openExternalWallet">
|
|
||||||
<Zap class="mr-2 h-3.5 w-3.5" />
|
|
||||||
Open in wallet
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
v-if="flow.hasWalletWithBalance.value"
|
|
||||||
size="lg"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="flow.isPayingWithWallet.value"
|
|
||||||
@click="flow.payWithWallet"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="flow.isPayingWithWallet.value" class="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
<Wallet v-else class="mr-2 h-4 w-4" />
|
|
||||||
{{ flow.isPayingWithWallet.value ? 'Paying…' : 'Pay from my LNbits wallet' }}
|
|
||||||
</Button>
|
|
||||||
<p
|
|
||||||
v-else-if="flow.userWallets.value.length > 0"
|
|
||||||
class="text-center text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Your LNbits wallet is empty — pay with an external wallet using the QR
|
|
||||||
or "Open in wallet" above.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="flow.isPaymentPending.value" class="text-center space-y-1">
|
|
||||||
<div class="flex items-center justify-center gap-2">
|
|
||||||
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
|
||||||
<span class="text-sm text-muted-foreground">Waiting for payment…</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Your dates are held<template v-if="holdExpiry"> until {{ holdExpiry }}</template>.
|
|
||||||
Confirmation lands automatically — no need to refresh.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="confirmClose" class="text-center text-xs text-destructive">
|
|
||||||
Close without paying? The hold expires on its own. Tap outside again to close.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3: paid -->
|
|
||||||
<div v-else class="py-4 space-y-4 text-center">
|
|
||||||
<CheckCircle2 class="mx-auto h-12 w-12 text-primary" />
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h3 class="text-lg font-semibold">You're booked</h3>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{{ stayLabel }} · {{ nightsLabel }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-muted/50 p-3 text-sm space-y-1 text-left">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">Booking</span>
|
|
||||||
<span class="font-mono">{{ flow.booking.value?.id }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">Paid</span>
|
|
||||||
<span class="font-semibold">{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Check-in details arrive as a private Nostr message from the host.
|
|
||||||
</p>
|
|
||||||
<Button class="w-full" @click="emit('update:isOpen', false)">Done</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
|
||||||
const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE)
|
|
||||||
|
|
||||||
const quote = ref<BookingQuote | null>(null)
|
|
||||||
const qrCode = ref<string | null>(null)
|
|
||||||
const isRequesting = ref(false)
|
|
||||||
const isPaymentPending = ref(false)
|
|
||||||
const isPaid = ref(false)
|
|
||||||
const copiedInvoice = ref(false)
|
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | 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<BookingQuote | null> {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,7 @@ export interface ChateletModuleConfig {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals
|
* Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals
|
||||||
* extension: discovery, availability and Lightning-paid booking over HTTP.
|
* extension. Slice 1: read-only discovery + availability over HTTP.
|
||||||
*/
|
*/
|
||||||
export const chateletModule = createModulePlugin({
|
export const chateletModule = createModulePlugin({
|
||||||
name: 'chatelet',
|
name: 'chatelet',
|
||||||
|
|
@ -52,4 +52,3 @@ export const chateletModule = createModulePlugin({
|
||||||
export default chateletModule
|
export default chateletModule
|
||||||
|
|
||||||
export type { Room, AvailabilityResult } from './types/room'
|
export type { Room, AvailabilityResult } from './types/room'
|
||||||
export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking'
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { AvailabilityResult, Room } from '../types/room'
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
import type { BookingQuote, BookingRequest, PaymentStatus } from '../types/booking'
|
|
||||||
|
|
||||||
export interface ChateletApiConfig {
|
export interface ChateletApiConfig {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
|
@ -10,8 +9,8 @@ export interface ChateletApiConfig {
|
||||||
* HTTP client for the LNbits `chatelet` extension's guest surface.
|
* HTTP client for the LNbits `chatelet` extension's guest surface.
|
||||||
*
|
*
|
||||||
* Plain config-wrapper (same pattern as events' TicketApiService) — not a
|
* Plain config-wrapper (same pattern as events' TicketApiService) — not a
|
||||||
* BaseService. Guest browsing, availability and the booking request are all
|
* BaseService. Slice 1 is read-only guest browsing over public endpoints;
|
||||||
* public endpoints; settlement is polled on the invoice via LNbits core.
|
* booking (POST /bookings) + status polling arrive in slice 2.
|
||||||
*
|
*
|
||||||
* Transport note: this is HTTP-for-now. The extension also exposes a
|
* Transport note: this is HTTP-for-now. The extension also exposes a
|
||||||
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
|
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
|
||||||
|
|
@ -42,30 +41,6 @@ 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<BookingQuote> {
|
|
||||||
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<PaymentStatus> {
|
|
||||||
return this.request(`/api/v1/payments/${paymentHash}`, { method: 'GET' })
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { addDays, format } from 'date-fns'
|
import { addDays, format } from 'date-fns'
|
||||||
import { MapPin, Users } from 'lucide-vue-next'
|
import { MapPin, Users } from 'lucide-vue-next'
|
||||||
|
|
@ -9,14 +9,10 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useChatelet } from '../composables/useChatelet'
|
import { useChatelet } from '../composables/useChatelet'
|
||||||
import BookRoomDialog from '../components/BookRoomDialog.vue'
|
|
||||||
import type { AvailabilityResult, Room } from '../types/room'
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const { isAuthenticated } = useAuth()
|
|
||||||
const { getRoom, checkAvailability } = useChatelet()
|
const { getRoom, checkAvailability } = useChatelet()
|
||||||
|
|
||||||
const room = ref<Room | null>(null)
|
const room = ref<Room | null>(null)
|
||||||
|
|
@ -25,10 +21,6 @@ const checkIn = ref('')
|
||||||
const checkOut = ref('')
|
const checkOut = ref('')
|
||||||
const checking = ref(false)
|
const checking = ref(false)
|
||||||
const result = ref<AvailabilityResult | null>(null)
|
const result = ref<AvailabilityResult | null>(null)
|
||||||
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<AvailabilityResult | null>(null)
|
|
||||||
|
|
||||||
// Same YYYY-MM-DD wire format the DatePicker speaks; the availability
|
// Same YYYY-MM-DD wire format the DatePicker speaks; the availability
|
||||||
// endpoint takes the same strings.
|
// endpoint takes the same strings.
|
||||||
|
|
@ -50,8 +42,6 @@ watch(checkOut, () => {
|
||||||
result.value = null
|
result.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
const YMD = /^\d{4}-\d{2}-\d{2}$/
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
room.value = await getRoom(route.params.id as string)
|
room.value = await getRoom(route.params.id as string)
|
||||||
|
|
@ -60,14 +50,6 @@ onMounted(async () => {
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
// Dates survive a trip through /login (see onBook) as query params.
|
|
||||||
const qi = route.query.check_in
|
|
||||||
const qo = route.query.check_out
|
|
||||||
if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) {
|
|
||||||
checkIn.value = qi
|
|
||||||
checkOut.value = qo
|
|
||||||
if (room.value) await onCheck()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onCheck() {
|
async function onCheck() {
|
||||||
|
|
@ -82,36 +64,6 @@ async function onCheck() {
|
||||||
checking.value = false
|
checking.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Booking needs a Nostr identity (the extension DMs check-in details to
|
|
||||||
* the guest pubkey), so anonymous guests bounce through /login and come
|
|
||||||
* back here with their dates intact. Guest checkout is a later slice.
|
|
||||||
*/
|
|
||||||
function onBook() {
|
|
||||||
if (!result.value?.available) return
|
|
||||||
if (!isAuthenticated.value) {
|
|
||||||
const back = router.resolve({
|
|
||||||
path: route.path,
|
|
||||||
query: { check_in: checkIn.value, check_out: checkOut.value },
|
|
||||||
}).fullPath
|
|
||||||
router.push({ path: '/login', query: { redirect: back } })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bookingQuote.value = result.value
|
|
||||||
showBooking.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The dates are taken now — drop the stale quote so the card resets. */
|
|
||||||
function onBooked() {
|
|
||||||
result.value = null
|
|
||||||
checkIn.value = ''
|
|
||||||
checkOut.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(showBooking, (open) => {
|
|
||||||
if (!open) bookingQuote.value = null
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -186,22 +138,12 @@ watch(showBooking, (open) => {
|
||||||
(≈ {{ result.quote_fiat }} {{ result.currency }})
|
(≈ {{ result.quote_fiat }} {{ result.currency }})
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<Button class="w-full mt-3" @click="onBook">
|
<Button class="w-full mt-3" disabled>Book — coming soon</Button>
|
||||||
{{ isAuthenticated ? 'Book' : 'Sign in to book' }}
|
|
||||||
</Button>
|
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="text-destructive">Not available for those dates.</p>
|
<p v-else class="text-destructive">Not available for those dates.</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BookRoomDialog
|
|
||||||
v-if="room && bookingQuote"
|
|
||||||
:room="room"
|
|
||||||
:availability="bookingQuote"
|
|
||||||
v-model:is-open="showBooking"
|
|
||||||
@booked="onBooked"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { postLoginTarget } from '@/lib/router-helpers'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -121,7 +120,6 @@ import { auth } from '@/composables/useAuthService'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
|
|
@ -157,9 +155,9 @@ async function handleLogin() {
|
||||||
successMessage.value = 'Login successful! Redirecting...'
|
successMessage.value = 'Login successful! Redirecting...'
|
||||||
toast.success('Login successful!')
|
toast.success('Login successful!')
|
||||||
|
|
||||||
// Back to where the user came from (?redirect=), else home
|
// Redirect to home page after successful login
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(postLoginTarget(route.query.redirect))
|
router.push('/')
|
||||||
}, 1500)
|
}, 1500)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { postLoginTarget } from '@/lib/router-helpers'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -151,7 +150,6 @@ import { useDemoAccountGenerator } from '@/composables/useDemoAccountGenerator'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
|
||||||
const { isLoading, error, generateNewCredentials } = useDemoAccountGenerator()
|
const { isLoading, error, generateNewCredentials } = useDemoAccountGenerator()
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
const activeMode = ref<'demo' | 'login'>('demo')
|
const activeMode = ref<'demo' | 'login'>('demo')
|
||||||
|
|
@ -188,9 +186,9 @@ async function createFakeAccount() {
|
||||||
successMessage.value = `Account created! Username: ${credentials.username}`
|
successMessage.value = `Account created! Username: ${credentials.username}`
|
||||||
toast.success(`Logged in as ${credentials.username}!`)
|
toast.success(`Logged in as ${credentials.username}!`)
|
||||||
|
|
||||||
// Back to where the user came from (?redirect=), else home
|
// Redirect to home page after successful registration
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(postLoginTarget(route.query.redirect))
|
router.push('/')
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -218,9 +216,9 @@ async function handleLogin() {
|
||||||
successMessage.value = 'Login successful! Redirecting...'
|
successMessage.value = 'Login successful! Redirecting...'
|
||||||
toast.success('Login successful!')
|
toast.success('Login successful!')
|
||||||
|
|
||||||
// Back to where the user came from (?redirect=), else home
|
// Redirect to home page after successful login
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(postLoginTarget(route.query.redirect))
|
router.push('/')
|
||||||
}, 1500)
|
}, 1500)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue