diff --git a/src/chatelet-app/App.vue b/src/chatelet-app/App.vue index 0b1977e..b98cf17 100644 --- a/src/chatelet-app/App.vue +++ b/src/chatelet-app/App.vue @@ -1,13 +1,40 @@ diff --git a/src/components/ui/range-calendar/RangeCalendar.vue b/src/components/ui/range-calendar/RangeCalendar.vue new file mode 100644 index 0000000..2d11816 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendar.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarCell.vue b/src/components/ui/range-calendar/RangeCalendarCell.vue new file mode 100644 index 0000000..e5dea17 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue b/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue new file mode 100644 index 0000000..7971c55 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGrid.vue b/src/components/ui/range-calendar/RangeCalendarGrid.vue new file mode 100644 index 0000000..c1db6c9 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGrid.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGridBody.vue b/src/components/ui/range-calendar/RangeCalendarGridBody.vue new file mode 100644 index 0000000..68556dd --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGridBody.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGridHead.vue b/src/components/ui/range-calendar/RangeCalendarGridHead.vue new file mode 100644 index 0000000..f6cc09e --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGridHead.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGridRow.vue b/src/components/ui/range-calendar/RangeCalendarGridRow.vue new file mode 100644 index 0000000..717396c --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGridRow.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarHeadCell.vue b/src/components/ui/range-calendar/RangeCalendarHeadCell.vue new file mode 100644 index 0000000..7ea377f --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarHeadCell.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarHeader.vue b/src/components/ui/range-calendar/RangeCalendarHeader.vue new file mode 100644 index 0000000..5cef2d5 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarHeader.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarHeading.vue b/src/components/ui/range-calendar/RangeCalendarHeading.vue new file mode 100644 index 0000000..90845e3 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarHeading.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarNextButton.vue b/src/components/ui/range-calendar/RangeCalendarNextButton.vue new file mode 100644 index 0000000..f60c786 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarNextButton.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarPrevButton.vue b/src/components/ui/range-calendar/RangeCalendarPrevButton.vue new file mode 100644 index 0000000..8ed2032 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarPrevButton.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/ui/range-calendar/index.ts b/src/components/ui/range-calendar/index.ts new file mode 100644 index 0000000..34d5361 --- /dev/null +++ b/src/components/ui/range-calendar/index.ts @@ -0,0 +1,12 @@ +export { default as RangeCalendar } from "./RangeCalendar.vue" +export { default as RangeCalendarCell } from "./RangeCalendarCell.vue" +export { default as RangeCalendarCellTrigger } from "./RangeCalendarCellTrigger.vue" +export { default as RangeCalendarGrid } from "./RangeCalendarGrid.vue" +export { default as RangeCalendarGridBody } from "./RangeCalendarGridBody.vue" +export { default as RangeCalendarGridHead } from "./RangeCalendarGridHead.vue" +export { default as RangeCalendarGridRow } from "./RangeCalendarGridRow.vue" +export { default as RangeCalendarHeadCell } from "./RangeCalendarHeadCell.vue" +export { default as RangeCalendarHeader } from "./RangeCalendarHeader.vue" +export { default as RangeCalendarHeading } from "./RangeCalendarHeading.vue" +export { default as RangeCalendarNextButton } from "./RangeCalendarNextButton.vue" +export { default as RangeCalendarPrevButton } from "./RangeCalendarPrevButton.vue" diff --git a/src/modules/chatelet/components/BookRoomDialog.vue b/src/modules/chatelet/components/BookRoomDialog.vue index 61381ff..e928484 100644 --- a/src/modules/chatelet/components/BookRoomDialog.vue +++ b/src/modules/chatelet/components/BookRoomDialog.vue @@ -1,5 +1,6 @@ + + diff --git a/src/modules/chatelet/composables/useMyBookings.ts b/src/modules/chatelet/composables/useMyBookings.ts new file mode 100644 index 0000000..fe6682e --- /dev/null +++ b/src/modules/chatelet/composables/useMyBookings.ts @@ -0,0 +1,66 @@ +import { computed } from 'vue' +import { useAsyncState } from '@vueuse/core' +import { getLocalTimeZone, today } from '@internationalized/date' +import { useAuth } from '@/composables/useAuthService' +import { injectService, SERVICE_TOKENS, tryInjectService } from '@/core/di-container' +import type { ChateletApiService } from '../services/ChateletApiService' +import { LOST_BOOKING_STATUSES, type GuestBooking } from '../types/booking' +import { useChateletStore } from '../stores/chatelet' + +/** + * The signed-in guest's stays, split into upcoming and past. Room titles are + * joined client-side from the public room list (a handful of rooms per + * install); a room that has since gone inactive falls back to its id. + */ +export function useMyBookings() { + const { isAuthenticated, currentUser } = useAuth() + const api = tryInjectService(SERVICE_TOKENS.CHATELET_API) + const store = useChateletStore() + + const { + state: bookings, + isLoading, + error: asyncError, + execute: refresh, + } = useAsyncState( + async () => { + if (!api || !isAuthenticated.value || !currentUser.value) return [] + const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any + const accessToken: string = lnbitsAPI?.getAccessToken?.() || '' + return await api.getMyBookings(accessToken) + }, + [] as GuestBooking[], + { immediate: false, resetOnExecute: false }, + ) + + const error = computed(() => + asyncError.value + ? asyncError.value instanceof Error + ? asyncError.value.message + : 'Could not load your bookings' + : null, + ) + + const todayYmd = today(getLocalTimeZone()).toString() + + /** Still relevant: not over yet and the hold/booking is alive. */ + const upcoming = computed(() => + bookings.value + .filter((b) => b.check_out > todayYmd && !LOST_BOOKING_STATUSES.has(b.status)) + .sort((a, b) => a.check_in.localeCompare(b.check_in)), + ) + const past = computed(() => + bookings.value + .filter((b) => b.check_out <= todayYmd || LOST_BOOKING_STATUSES.has(b.status)) + .sort((a, b) => b.check_in.localeCompare(a.check_in)), + ) + + function roomTitle(roomId: string): string { + return store.rooms.find((r) => r.id === roomId)?.title ?? `Room ${roomId}` + } + function roomImage(roomId: string): string | undefined { + return store.rooms.find((r) => r.id === roomId)?.images?.[0] + } + + return { bookings, upcoming, past, isLoading, error, refresh, roomTitle, roomImage } +} diff --git a/src/modules/chatelet/composables/useRoomUnavailability.ts b/src/modules/chatelet/composables/useRoomUnavailability.ts new file mode 100644 index 0000000..684b931 --- /dev/null +++ b/src/modules/chatelet/composables/useRoomUnavailability.ts @@ -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) { + // DI, never a direct import (workspace rule). + const api = tryInjectService(SERVICE_TOKENS.CHATELET_API) + + const nights = ref([]) + const windowStart = ref(today(getLocalTimeZone()).toString()) + const windowEnd = ref(addDays(windowStart.value, WINDOW_DAYS)) + const isLoading = ref(false) + const error = ref(null) + + const nightSet = computed(() => new Set(nights.value)) + + async function refresh(): Promise { + 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 } +} diff --git a/src/modules/chatelet/index.ts b/src/modules/chatelet/index.ts index 8980b69..f71e445 100644 --- a/src/modules/chatelet/index.ts +++ b/src/modules/chatelet/index.ts @@ -31,6 +31,14 @@ export const chateletModule = createModulePlugin({ component: () => import('./views/ChateletDetailPage.vue'), meta: { title: 'Room', requiresAuth: false }, }, + { + // Mirrors events' /my-tickets; kept off /chatelet/* so it never + // collides with the :id room route. + path: '/my-bookings', + name: 'chatelet-my-bookings', + component: () => import('./views/MyBookingsPage.vue'), + meta: { title: 'My bookings', requiresAuth: true }, + }, ], onInstall: async (_app, options) => { @@ -52,4 +60,4 @@ export const chateletModule = createModulePlugin({ export default chateletModule export type { Room, AvailabilityResult } from './types/room' -export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking' +export type { Booking, BookingQuote, BookingRequest, BookingStatus, GuestBooking } from './types/booking' diff --git a/src/modules/chatelet/lib/unavailable.spec.ts b/src/modules/chatelet/lib/unavailable.spec.ts new file mode 100644 index 0000000..2756ce6 --- /dev/null +++ b/src/modules/chatelet/lib/unavailable.spec.ts @@ -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) + }) +}) diff --git a/src/modules/chatelet/lib/unavailable.ts b/src/modules/chatelet/lib/unavailable.ts new file mode 100644 index 0000000..0904b4c --- /dev/null +++ b/src/modules/chatelet/lib/unavailable.ts @@ -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() + 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, 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 +} diff --git a/src/modules/chatelet/services/ChateletApiService.ts b/src/modules/chatelet/services/ChateletApiService.ts index 2be1400..7378157 100644 --- a/src/modules/chatelet/services/ChateletApiService.ts +++ b/src/modules/chatelet/services/ChateletApiService.ts @@ -1,5 +1,5 @@ -import type { AvailabilityResult, Room } from '../types/room' -import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking' +import type { AvailabilityResult, Room, UnavailableRanges } from '../types/room' +import type { BookingQuote, BookingRequest, GuestBooking, PublicBooking } from '../types/booking' export interface ChateletApiConfig { baseUrl: string @@ -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 { + 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, @@ -64,6 +84,18 @@ export class ChateletApiService { return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' }) } + /** + * The signed-in guest's own stays, newest check-in first. LNbits account + * auth (Bearer), the same way events fetches a user's tickets; the backend + * matches on the account's Nostr pubkey. + */ + async getMyBookings(accessToken: string): Promise { + return this.request('/chatelet/api/v1/bookings/mine', { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + } + private async request(path: string, init: RequestInit = {}): Promise { const headers: Record = { accept: 'application/json', diff --git a/src/modules/chatelet/types/booking.ts b/src/modules/chatelet/types/booking.ts index 83c0831..56f685b 100644 --- a/src/modules/chatelet/types/booking.ts +++ b/src/modules/chatelet/types/booking.ts @@ -70,6 +70,13 @@ export type PublicBooking = Omit< 'guest_pubkey' | 'guest_contact' | 'payment_hash' > +/** + * A booking as returned to its own guest by `GET /chatelet/api/v1/bookings/mine` + * (LNbits account auth, chatelet ≥ v0.5.0): the guest keeps their contact and + * counts; the Lightning/Nostr plumbing stays server-side. + */ +export type GuestBooking = Omit + /** Statuses that mean the hold is gone and the invoice will never confirm. */ export const LOST_BOOKING_STATUSES: ReadonlySet = new Set([ 'expired', diff --git a/src/modules/chatelet/types/room.ts b/src/modules/chatelet/types/room.ts index 6800d6a..7115fa8 100644 --- a/src/modules/chatelet/types/room.ts +++ b/src/modules/chatelet/types/room.ts @@ -2,6 +2,13 @@ // Note: operator-private fields (wallet, checkin_instructions) are stripped // server-side by public_room_dict and never reach the client. +/** The room owner's rules, from their operator settings (chatelet ≥ v0.5.0). */ +export interface HouseRules { + checkin_time: string + checkout_time: string + cancellation_policy: string +} + export interface Room { id: string title: string @@ -19,6 +26,10 @@ export interface Room { listing_event_id: string | null created_at: string updated_at: string + /** Absent on chatelet < v0.5.0. */ + house_rules?: HouseRules + /** Rails the owner accepts for this room: 'lightning' and maybe 'fiat'. Absent on < v0.5.0. */ + payment_methods?: string[] } export interface AvailabilityResult { @@ -31,3 +42,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[] +} diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue index ae43195..612d425 100644 --- a/src/modules/chatelet/views/ChateletDetailPage.vue +++ b/src/modules/chatelet/views/ChateletDetailPage.vue @@ -2,16 +2,19 @@ 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 { MapPin, Users } from 'lucide-vue-next' +import { watchDebounced } from '@vueuse/core' +import { Clock, MapPin, Moon, 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 { ImageViewer } from '@/components/ui/image' +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 +33,24 @@ const showBooking = ref(false) * `result` being cleared once the dates are taken. */ const bookingQuote = ref(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 +67,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 +75,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 +111,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) => { @@ -120,11 +130,18 @@ watch(showBooking, (open) => {
Room not found.
- +
@@ -152,40 +169,66 @@ watch(showBooking, (open) => { {{ room.description }}

- - -
-
- - -
-
- - -
+ + + +

Things to know

+
    +
  • + + Check-in from {{ room.house_rules.checkin_time }} · check-out by + {{ room.house_rules.checkout_time }} +
  • +
  • + + Up to {{ room.max_guests }} guest{{ room.max_guests === 1 ? '' : 's' }} +
  • +
  • + + Minimum stay {{ room.min_nights }} night{{ room.min_nights === 1 ? '' : 's' }} +
  • +
+
+

Cancellation policy

+

+ {{ room.house_rules.cancellation_policy }} +

- +
+
-
+ + + +

+ Could not load the availability calendar — you can still pick dates and check them. +

+ +
+
+ Checking availability… +
+ +