feat(chatelet): availability calendar with Airbnb-style stay picking

Replace the two date pickers and the manual 'Check availability' button
with one range calendar fed by the extension's keyless unavailable-ranges
endpoint (chatelet ≥ v0.5.0): occupied nights are struck through, a stay
can never straddle one, and check-out may land on the morning another
guest arrives (half-open nights). Two months on sm+, one on phones.

reka-ui only guards clicks with isDateDisabled and hides selected
styling on isDateUnavailable days, so the matchers are selection-aware:
once a check-in is chosen the first occupied night after it stops being
unavailable (it is the last legal check-out) and every later day is
disabled; min_nights is enforced the same way. Both matchers close over
refs because reka reads them once at setup.

Picking a complete stay quotes it automatically; the quote card shows
rate × nights and the total. A quote that contradicts the local calendar
refreshes it (someone else's hold appeared), as does a confirmed booking.
The /login?redirect round-trip of dates is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-16 11:53:22 +02:00
commit 7dbabfaa81
7 changed files with 501 additions and 54 deletions

View file

@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import {
CalendarDate,
getLocalTimeZone,
parseDate,
today,
type DateValue,
} from '@internationalized/date'
import type { DateRange as RekaDateRange } from 'reka-ui'
import { format } from 'date-fns'
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { RangeCalendar } from '@/components/ui/range-calendar'
import { cn } from '@/lib/utils'
import { addDays, nextUnavailableOnOrAfter, nightsBetween, stayIsFree } from '../lib/unavailable'
/**
* Airbnb-style stay picker: one range calendar where occupied nights are
* struck through, check-out may land on the morning another guest arrives,
* and a stay can never straddle an occupied night.
*
* reka-ui only guards clicks with `isDateDisabled` and, with contiguous
* ranges, hides the selected styling on `isDateUnavailable` days — so the
* matchers below are selection-aware: once a check-in is chosen, the first
* occupied night after it stops being "unavailable" (it is a legal
* check-out) and every later day is disabled. Both matchers close over refs,
* because reka reads them once at setup.
*/
const props = defineProps<{
/** YYYY-MM-DD or '' */
checkIn: string
checkOut: string
/** Sorted occupied nights (YYYY-MM-DD) and the same as a Set. */
nights: string[]
nightSet: Set<string>
minNights: number
/** Last selectable day, YYYY-MM-DD (the loaded window end). */
maxDate?: string
}>()
const emit = defineEmits<{
'update:checkIn': [value: string]
'update:checkOut': [value: string]
}>()
const isWide = useMediaQuery('(min-width: 640px)')
const todayYmd = today(getLocalTimeZone()).toString()
function toYmd(d: DateValue | undefined): string {
return d ? new CalendarDate(d.year, d.month, d.day).toString() : ''
}
function fromYmd(ymd: string): CalendarDate | undefined {
if (!ymd) return undefined
try {
return parseDate(ymd)
} catch {
return undefined
}
}
// --- selection state -------------------------------------------------------
/** The in-progress check-in while the guest is still picking a check-out. */
const pendingStart = ref<string>('')
const bothSet = computed(() => Boolean(props.checkIn && props.checkOut))
const activeStart = computed(() => (bothSet.value ? '' : pendingStart.value || props.checkIn))
/** First occupied night after the chosen check-in: the last legal check-out. */
const boundary = computed(() =>
activeStart.value
? nextUnavailableOnOrAfter(props.nights, addDays(activeStart.value, 1))
: undefined,
)
// shallowRef: DateValue instances must not be made deeply reactive (their
// private fields break reka's typings and identity checks).
const model = shallowRef<RekaDateRange>({ start: fromYmd(props.checkIn), end: fromYmd(props.checkOut) })
watch(
() => [props.checkIn, props.checkOut] as const,
([ci, co]) => {
model.value = { start: fromYmd(ci), end: fromYmd(co) }
if (ci && co) pendingStart.value = ''
},
)
const placeholder = shallowRef<DateValue>(fromYmd(props.checkIn) ?? today(getLocalTimeZone()))
const minValue = today(getLocalTimeZone())
const maxValue = computed(() => (props.maxDate ? fromYmd(props.maxDate) : undefined))
// --- matchers (stable functions over reactive state) ------------------------
function isDateUnavailable(d: DateValue): boolean {
const y = toYmd(d)
if (!props.nightSet.has(y)) return false
// The boundary night is a legal check-out, and a committed check-out that
// sits on an occupied night must keep rendering as selected.
if (y === boundary.value || (bothSet.value && y === props.checkOut)) return false
return true
}
function isDateDisabled(d: DateValue): boolean {
const y = toYmd(d)
if (y < todayYmd) return true
const start = activeStart.value
if (!start) {
// Picking a check-in: occupied nights are out; everything else is fair.
return props.nightSet.has(y) && !(bothSet.value && y === props.checkOut)
}
if (y === start) return false
if (y > start) {
if (y < addDays(start, props.minNights)) return true
if (boundary.value && y > boundary.value) return true
return false
}
// Clicking before the chosen check-in restarts the range there.
return props.nightSet.has(y)
}
// --- events -----------------------------------------------------------------
function onStartValue(v: DateValue | undefined) {
pendingStart.value = toYmd(v)
}
function onModel(v: RekaDateRange | null) {
const ci = toYmd(v?.start)
const co = toYmd(v?.end)
if (ci && co) {
const ok =
stayIsFree(props.nightSet, ci, co) && nightsBetween(ci, co) >= props.minNights
if (!ok) {
// Defensive: reka let a bad range through — keep the check-in, redo the end.
pendingStart.value = ci
emit('update:checkIn', ci)
emit('update:checkOut', '')
return
}
pendingStart.value = ''
emit('update:checkIn', ci)
emit('update:checkOut', co)
return
}
if (ci && !co) {
pendingStart.value = ci
emit('update:checkIn', ci)
emit('update:checkOut', '')
return
}
pendingStart.value = ''
emit('update:checkIn', '')
emit('update:checkOut', '')
}
function clear() {
onModel(null)
}
function clearCheckOut() {
if (!props.checkIn) return
onModel({ start: fromYmd(props.checkIn), end: undefined })
}
// --- display ----------------------------------------------------------------
function pretty(ymd: string): string {
return ymd ? format(new Date(ymd + 'T00:00:00'), 'MMM d, yyyy') : ''
}
const nights = computed(() => (bothSet.value ? nightsBetween(props.checkIn, props.checkOut) : 0))
const title = computed(() => {
if (bothSet.value) return `${nights.value} night${nights.value === 1 ? '' : 's'}`
if (props.checkIn) return 'Select check-out date'
return 'Select check-in date'
})
const subtitle = computed(() => {
if (bothSet.value) return `${pretty(props.checkIn)} – ${pretty(props.checkOut)}`
if (props.checkIn) return `Minimum stay ${props.minNights} night${props.minNights === 1 ? '' : 's'}`
return 'Add your travel dates for exact pricing'
})
</script>
<template>
<div class="space-y-4">
<!-- Summary header + the two labelled date boxes -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-lg font-semibold text-foreground">{{ title }}</p>
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
</div>
<div class="grid grid-cols-2 rounded-lg border border-input text-left">
<div
:class="cn('px-3 py-2', !checkIn && 'ring-2 ring-ring rounded-l-lg')"
>
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-in</p>
<div class="flex items-center justify-between gap-1">
<span :class="cn('text-sm', checkIn ? 'text-foreground' : 'text-muted-foreground')">
{{ checkIn ? pretty(checkIn) : 'Add date' }}
</span>
<button
v-if="checkIn"
type="button"
class="rounded-sm opacity-60 hover:opacity-100"
aria-label="Clear check-in"
@click="clear"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
<div
:class="cn('border-l border-input px-3 py-2', checkIn && !checkOut && 'ring-2 ring-ring rounded-r-lg')"
>
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-out</p>
<div class="flex items-center justify-between gap-1">
<span :class="cn('text-sm', checkOut ? 'text-foreground' : 'text-muted-foreground')">
{{ checkOut ? pretty(checkOut) : 'Add date' }}
</span>
<button
v-if="checkOut"
type="button"
class="rounded-sm opacity-60 hover:opacity-100"
aria-label="Clear check-out"
@click="clearCheckOut"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
<RangeCalendar
:model-value="model"
v-model:placeholder="placeholder"
:number-of-months="isWide ? 2 : 1"
paged-navigation
:week-starts-on="1"
:min-value="minValue"
:max-value="maxValue"
:is-date-unavailable="isDateUnavailable"
:is-date-disabled="isDateDisabled"
class="mx-auto w-fit p-0"
@update:model-value="onModel"
@update:start-value="onStartValue"
/>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5">
<span class="inline-block h-3 w-3 rounded-sm bg-muted line-through opacity-70"></span>
Unavailable
</span>
<Button variant="ghost" size="sm" :disabled="!checkIn" @click="clear">Clear dates</Button>
</div>
</div>
</template>

View file

@ -0,0 +1,48 @@
import { computed, ref, watch, type Ref } from 'vue'
import { getLocalTimeZone, today } from '@internationalized/date'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { ChateletApiService } from '../services/ChateletApiService'
import { addDays, expandRanges } from '../lib/unavailable'
const WINDOW_DAYS = 365
/**
* The occupied nights of one room for the calendar. One fetch of a 12-month
* window per room — a few spans, cheap — and the calendar's `maxValue` is
* pinned to the window end, so no refetch on month navigation. `refresh()`
* after a booking lands, or when a quote comes back "not available" for a
* stay the local set thought free (someone else's hold appeared).
*/
export function useRoomUnavailability(roomId: Ref<string | undefined>) {
// DI, never a direct import (workspace rule).
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
const nights = ref<string[]>([])
const windowStart = ref(today(getLocalTimeZone()).toString())
const windowEnd = ref(addDays(windowStart.value, WINDOW_DAYS))
const isLoading = ref(false)
const error = ref<string | null>(null)
const nightSet = computed(() => new Set(nights.value))
async function refresh(): Promise<void> {
if (!api || !roomId.value) return
isLoading.value = true
error.value = null
try {
const start = today(getLocalTimeZone()).toString()
const res = await api.getUnavailableRanges(roomId.value, start, addDays(start, WINDOW_DAYS))
windowStart.value = res.start
windowEnd.value = res.end
nights.value = expandRanges(res.ranges)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Could not load availability'
} finally {
isLoading.value = false
}
}
watch(roomId, () => void refresh(), { immediate: true })
return { nights, nightSet, windowStart, windowEnd, isLoading, error, refresh }
}

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
addDays,
expandRanges,
nextUnavailableOnOrAfter,
nightsBetween,
stayIsFree,
} from './unavailable'
describe('unavailable-night helpers', () => {
it('addDays crosses month and year boundaries', () => {
expect(addDays('2026-10-31', 1)).toBe('2026-11-01')
expect(addDays('2026-12-31', 1)).toBe('2027-01-01')
expect(addDays('2026-03-01', -1)).toBe('2026-02-28')
})
it('nightsBetween is check_out-exclusive', () => {
expect(nightsBetween('2026-10-05', '2026-10-07')).toBe(2)
expect(nightsBetween('2026-10-05', '2026-10-05')).toBe(0)
})
it('expandRanges yields sorted, de-duplicated occupied nights (end exclusive)', () => {
const nights = expandRanges([
{ start: '2026-10-10', end: '2026-10-12' },
{ start: '2026-10-05', end: '2026-10-07' },
{ start: '2026-10-11', end: '2026-10-13' },
])
expect(nights).toEqual(['2026-10-05', '2026-10-06', '2026-10-10', '2026-10-11', '2026-10-12'])
})
it('nextUnavailableOnOrAfter finds the boundary night', () => {
const nights = ['2026-10-05', '2026-10-06', '2026-10-10']
expect(nextUnavailableOnOrAfter(nights, '2026-10-01')).toBe('2026-10-05')
expect(nextUnavailableOnOrAfter(nights, '2026-10-05')).toBe('2026-10-05')
expect(nextUnavailableOnOrAfter(nights, '2026-10-07')).toBe('2026-10-10')
expect(nextUnavailableOnOrAfter(nights, '2026-10-11')).toBeUndefined()
expect(nextUnavailableOnOrAfter([], '2026-10-11')).toBeUndefined()
})
it('stayIsFree honours half-open semantics', () => {
const set = new Set(['2026-10-05', '2026-10-06'])
// checking out the morning the other guest checks in is fine
expect(stayIsFree(set, '2026-10-03', '2026-10-05')).toBe(true)
// checking in the morning they leave is fine too
expect(stayIsFree(set, '2026-10-07', '2026-10-09')).toBe(true)
// spanning an occupied night is not
expect(stayIsFree(set, '2026-10-04', '2026-10-06')).toBe(false)
expect(stayIsFree(set, '2026-10-06', '2026-10-06')).toBe(false)
})
})

View file

@ -0,0 +1,49 @@
import type { DateRange } from '../types/room'
/** YYYY-MM-DD → YYYY-MM-DD, `days` later. Date-only math, no timezone. */
export function addDays(ymd: string, days: number): string {
const d = new Date(ymd + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/** Nights between two YYYY-MM-DD dates (check_out exclusive). */
export function nightsBetween(checkIn: string, checkOut: string): number {
const a = Date.UTC(+checkIn.slice(0, 4), +checkIn.slice(5, 7) - 1, +checkIn.slice(8, 10))
const b = Date.UTC(+checkOut.slice(0, 4), +checkOut.slice(5, 7) - 1, +checkOut.slice(8, 10))
return Math.round((b - a) / 86_400_000)
}
/**
* Expand half-open [start, end) spans into the sorted, de-duplicated list of
* occupied nights. A year of a busy room is a few hundred strings — cheap to
* hold and to binary-search.
*/
export function expandRanges(ranges: DateRange[]): string[] {
const out = new Set<string>()
for (const r of ranges) {
for (let d = r.start; d < r.end; d = addDays(d, 1)) out.add(d)
}
return [...out].sort()
}
/** First occupied night ≥ `ymd`, or undefined if none in the loaded window. */
export function nextUnavailableOnOrAfter(nights: string[], ymd: string): string | undefined {
let lo = 0
let hi = nights.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (nights[mid] < ymd) lo = mid + 1
else hi = mid
}
return nights[lo]
}
/** True when every night of [checkIn, checkOut) is free. */
export function stayIsFree(nightSet: Set<string>, checkIn: string, checkOut: string): boolean {
if (!(checkIn < checkOut)) return false
for (let d = checkIn; d < checkOut; d = addDays(d, 1)) {
if (nightSet.has(d)) return false
}
return true
}

View file

@ -1,4 +1,4 @@
import type { AvailabilityResult, Room } from '../types/room' import type { AvailabilityResult, Room, UnavailableRanges } from '../types/room'
import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking' import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking'
export interface ChateletApiConfig { export interface ChateletApiConfig {
@ -30,6 +30,26 @@ export class ChateletApiService {
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' }) return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
} }
/**
* Nights a guest cannot book over a window (default today → +365 d): live
* holds, confirmed stays and manual blocks merged into anonymous spans.
* Keyless, like the room read it hangs off.
*/
async getUnavailableRanges(
roomId: string,
start?: string,
end?: string,
): Promise<UnavailableRanges> {
const q = new URLSearchParams()
if (start) q.set('start', start)
if (end) q.set('end', end)
const qs = q.toString()
return this.request(
`/chatelet/api/v1/public/rooms/${roomId}/unavailable${qs ? `?${qs}` : ''}`,
{ method: 'GET' },
)
}
async checkAvailability( async checkAvailability(
roomId: string, roomId: string,
checkIn: string, checkIn: string,

View file

@ -31,3 +31,17 @@ export interface AvailabilityResult {
quote_fiat: number | null quote_fiat: number | null
currency: string | null currency: string | null
} }
/** Half-open [start, end) span of occupied nights; `end` is the morning the room frees up. */
export interface DateRange {
start: string
end: string
}
/** `GET /chatelet/api/v1/public/rooms/{id}/unavailable` — the guest calendar feed. */
export interface UnavailableRanges {
room_id: string
start: string
end: string
ranges: DateRange[]
}

View file

@ -2,16 +2,18 @@
import { computed, onMounted, ref, watch } from 'vue' 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 { addDays, format } from 'date-fns' import { watchDebounced } from '@vueuse/core'
import { MapPin, Users } from 'lucide-vue-next' import { MapPin, 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'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { formatSats } from '@/lib/utils/formatting'
import DatePicker from '@/modules/base/components/DatePicker.vue'
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 { stayIsFree } from '../lib/unavailable'
import BookRoomDialog from '../components/BookRoomDialog.vue' import BookRoomDialog from '../components/BookRoomDialog.vue'
import StayDatePicker from '../components/StayDatePicker.vue'
import type { AvailabilityResult, Room } from '../types/room' import type { AvailabilityResult, Room } from '../types/room'
const route = useRoute() const route = useRoute()
@ -30,26 +32,24 @@ const showBooking = ref(false)
* `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)
// Same YYYY-MM-DD wire format the DatePicker speaks; the availability // Occupied nights for the calendar; refreshed after a booking and when a
// endpoint takes the same strings. // quote contradicts the local set (someone else's hold appeared).
const today = computed(() => format(new Date(), 'yyyy-MM-dd')) const roomIdRef = computed(() => route.params.id as string | undefined)
const unavailability = useRoomUnavailability(roomIdRef)
// Check-out must be at least the next morning. Bumping the floor also // Any date change invalidates the last quote; a complete stay re-quotes
// clears a check-out that the new check-in has overtaken. // itself (the availability call is the price preview, so no button).
const minCheckOut = computed(() => watch([checkIn, checkOut], () => {
checkIn.value result.value = null
? format(addDays(new Date(checkIn.value + 'T00:00:00'), 1), 'yyyy-MM-dd') })
: today.value, watchDebounced(
[checkIn, checkOut],
([ci, co]) => {
if (ci && co && room.value) void onCheck()
},
{ debounce: 150 },
) )
watch(checkIn, () => {
if (checkOut.value && checkOut.value < minCheckOut.value) checkOut.value = ''
result.value = null
})
watch(checkOut, () => {
result.value = null
})
const YMD = /^\d{4}-\d{2}-\d{2}$/ const YMD = /^\d{4}-\d{2}-\d{2}$/
onMounted(async () => { onMounted(async () => {
@ -66,7 +66,6 @@ onMounted(async () => {
if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) { if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) {
checkIn.value = qi checkIn.value = qi
checkOut.value = qo checkOut.value = qo
if (room.value) await onCheck()
} }
}) })
@ -75,7 +74,16 @@ async function onCheck() {
checking.value = true checking.value = true
result.value = null result.value = null
try { try {
result.value = await checkAvailability(room.value.id, checkIn.value, checkOut.value) const ci = checkIn.value
const co = checkOut.value
const r = await checkAvailability(room.value.id, ci, co)
// Only the quote for the dates still selected counts (debounce race).
if (ci !== checkIn.value || co !== checkOut.value) return
result.value = r
if (r && !r.available && stayIsFree(unavailability.nightSet.value, ci, co)) {
// The calendar thought these nights were free — it is stale.
void unavailability.refresh()
}
} catch (e: unknown) { } catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Availability check failed') toast.error(e instanceof Error ? e.message : 'Availability check failed')
} finally { } finally {
@ -102,11 +110,12 @@ function onBook() {
showBooking.value = true showBooking.value = true
} }
/** The dates are taken now — drop the stale quote so the card resets. */ /** The dates are taken now — drop the stale quote and grey them out. */
function onBooked() { function onBooked() {
result.value = null result.value = null
checkIn.value = '' checkIn.value = ''
checkOut.value = '' checkOut.value = ''
void unavailability.refresh()
} }
watch(showBooking, (open) => { watch(showBooking, (open) => {
@ -153,39 +162,36 @@ watch(showBooking, (open) => {
</p> </p>
<Card> <Card>
<CardContent class="p-4 space-y-3"> <CardContent class="p-4 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <StayDatePicker
<div class="space-y-1 min-w-0"> v-model:check-in="checkIn"
<Label>Check-in</Label> v-model:check-out="checkOut"
<DatePicker v-model="checkIn" :min="today" placeholder="Check-in" /> :nights="unavailability.nights.value"
</div> :night-set="unavailability.nightSet.value"
<div class="space-y-1 min-w-0"> :min-nights="room.min_nights"
<Label>Check-out</Label> :max-date="unavailability.windowEnd.value"
<DatePicker />
v-model="checkOut" <p v-if="unavailability.error.value" class="text-xs text-muted-foreground">
:min="minCheckOut" Could not load the availability calendar — you can still pick dates and check them.
:disabled="!checkIn" </p>
placeholder="Check-out"
/>
</div>
</div>
<Button
class="w-full"
:disabled="!checkIn || !checkOut || checking"
@click="onCheck"
>
{{ checking ? 'Checking…' : 'Check availability' }}
</Button>
<div v-if="result" class="text-sm pt-1"> <div v-if="checking" class="flex items-center gap-2 text-sm text-muted-foreground">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
Checking availability…
</div>
<div v-if="result && !checking" class="text-sm">
<template v-if="result.available"> <template v-if="result.available">
<p class="text-foreground"> <div class="rounded-lg bg-muted/50 p-3 space-y-1">
Available for {{ result.nights }} night(s) — <div class="flex justify-between text-muted-foreground">
<span class="font-semibold">{{ result.quote_sat }} sats</span> <span>{{ room.price_amount }} {{ room.price_currency }} × {{ result.nights }} night{{ result.nights === 1 ? '' : 's' }}</span>
<span v-if="result.quote_fiat" class="text-muted-foreground"> <span v-if="result.quote_fiat != null">{{ result.quote_fiat }} {{ result.currency }}</span>
(≈ {{ result.quote_fiat }} {{ result.currency }}) </div>
</span> <div class="flex justify-between font-semibold text-foreground">
</p> <span>Total</span>
<span v-if="result.quote_sat != null">{{ formatSats(result.quote_sat) }}</span>
</div>
</div>
<Button class="w-full mt-3" @click="onBook"> <Button class="w-full mt-3" @click="onBook">
{{ isAuthenticated ? 'Book' : 'Sign in to book' }} {{ isAuthenticated ? 'Book' : 'Sign in to book' }}
</Button> </Button>