feat(chatelet): stay calendar, room gallery + house rules, My bookings #156
7 changed files with 501 additions and 54 deletions
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>
commit
7dbabfaa81
260
src/modules/chatelet/components/StayDatePicker.vue
Normal file
260
src/modules/chatelet/components/StayDatePicker.vue
Normal 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>
|
||||
48
src/modules/chatelet/composables/useRoomUnavailability.ts
Normal file
48
src/modules/chatelet/composables/useRoomUnavailability.ts
Normal 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 }
|
||||
}
|
||||
50
src/modules/chatelet/lib/unavailable.spec.ts
Normal file
50
src/modules/chatelet/lib/unavailable.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
49
src/modules/chatelet/lib/unavailable.ts
Normal file
49
src/modules/chatelet/lib/unavailable.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
export interface ChateletApiConfig {
|
||||
|
|
@ -30,6 +30,26 @@ export class ChateletApiService {
|
|||
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(
|
||||
roomId: string,
|
||||
checkIn: string,
|
||||
|
|
|
|||
|
|
@ -31,3 +31,17 @@ export interface AvailabilityResult {
|
|||
quote_fiat: number | 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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@
|
|||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { addDays, format } from 'date-fns'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { MapPin, Users } from 'lucide-vue-next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||
import { formatSats } from '@/lib/utils/formatting'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useChatelet } from '../composables/useChatelet'
|
||||
import { useRoomUnavailability } from '../composables/useRoomUnavailability'
|
||||
import { stayIsFree } from '../lib/unavailable'
|
||||
import BookRoomDialog from '../components/BookRoomDialog.vue'
|
||||
import StayDatePicker from '../components/StayDatePicker.vue'
|
||||
import type { AvailabilityResult, Room } from '../types/room'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -30,26 +32,24 @@ const showBooking = ref(false)
|
|||
* `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
|
||||
// endpoint takes the same strings.
|
||||
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
|
||||
// Occupied nights for the calendar; refreshed after a booking and when a
|
||||
// quote contradicts the local set (someone else's hold appeared).
|
||||
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
|
||||
// clears a check-out that the new check-in has overtaken.
|
||||
const minCheckOut = computed(() =>
|
||||
checkIn.value
|
||||
? format(addDays(new Date(checkIn.value + 'T00:00:00'), 1), 'yyyy-MM-dd')
|
||||
: today.value,
|
||||
// Any date change invalidates the last quote; a complete stay re-quotes
|
||||
// itself (the availability call is the price preview, so no button).
|
||||
watch([checkIn, checkOut], () => {
|
||||
result.value = null
|
||||
})
|
||||
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}$/
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -66,7 +66,6 @@ onMounted(async () => {
|
|||
if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) {
|
||||
checkIn.value = qi
|
||||
checkOut.value = qo
|
||||
if (room.value) await onCheck()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -75,7 +74,16 @@ async function onCheck() {
|
|||
checking.value = true
|
||||
result.value = null
|
||||
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) {
|
||||
toast.error(e instanceof Error ? e.message : 'Availability check failed')
|
||||
} finally {
|
||||
|
|
@ -102,11 +110,12 @@ function onBook() {
|
|||
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() {
|
||||
result.value = null
|
||||
checkIn.value = ''
|
||||
checkOut.value = ''
|
||||
void unavailability.refresh()
|
||||
}
|
||||
|
||||
watch(showBooking, (open) => {
|
||||
|
|
@ -153,39 +162,36 @@ watch(showBooking, (open) => {
|
|||
</p>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-4 space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label>Check-in</Label>
|
||||
<DatePicker v-model="checkIn" :min="today" placeholder="Check-in" />
|
||||
</div>
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label>Check-out</Label>
|
||||
<DatePicker
|
||||
v-model="checkOut"
|
||||
:min="minCheckOut"
|
||||
:disabled="!checkIn"
|
||||
placeholder="Check-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="!checkIn || !checkOut || checking"
|
||||
@click="onCheck"
|
||||
>
|
||||
{{ checking ? 'Checking…' : 'Check availability' }}
|
||||
</Button>
|
||||
<CardContent class="p-4 space-y-4">
|
||||
<StayDatePicker
|
||||
v-model:check-in="checkIn"
|
||||
v-model:check-out="checkOut"
|
||||
:nights="unavailability.nights.value"
|
||||
:night-set="unavailability.nightSet.value"
|
||||
:min-nights="room.min_nights"
|
||||
:max-date="unavailability.windowEnd.value"
|
||||
/>
|
||||
<p v-if="unavailability.error.value" class="text-xs text-muted-foreground">
|
||||
Could not load the availability calendar — you can still pick dates and check them.
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<p class="text-foreground">
|
||||
Available for {{ result.nights }} night(s) —
|
||||
<span class="font-semibold">{{ result.quote_sat }} sats</span>
|
||||
<span v-if="result.quote_fiat" class="text-muted-foreground">
|
||||
(≈ {{ result.quote_fiat }} {{ result.currency }})
|
||||
</span>
|
||||
</p>
|
||||
<div class="rounded-lg bg-muted/50 p-3 space-y-1">
|
||||
<div class="flex justify-between text-muted-foreground">
|
||||
<span>{{ room.price_amount }} {{ room.price_currency }} × {{ result.nights }} night{{ result.nights === 1 ? '' : 's' }}</span>
|
||||
<span v-if="result.quote_fiat != null">{{ result.quote_fiat }} {{ result.currency }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between font-semibold text-foreground">
|
||||
<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">
|
||||
{{ isAuthenticated ? 'Book' : 'Sign in to book' }}
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue