From 6ea94692a1aaeab3303ef15a9f0065a1d25e9919 Mon Sep 17 00:00:00 2001
From: Padreug
Date: Wed, 16 Sep 2026 11:53:22 +0200
Subject: [PATCH 1/4] feat(ui): shadcn range-calendar primitive over reka-ui
RangeCalendarRoot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Port of ui/calendar onto reka-ui's RangeCalendar* parts, same slot shape.
The cell trigger gains range states — accent tint for the nights inside
the range, primary pills on the two ends — and a real unavailable look
(struck through, muted, not a pointer target).
Co-Authored-By: Claude Fable 5.1
---
.../ui/range-calendar/RangeCalendar.vue | 58 +++++++++++++++++++
.../ui/range-calendar/RangeCalendarCell.vue | 22 +++++++
.../RangeCalendarCellTrigger.vue | 40 +++++++++++++
.../ui/range-calendar/RangeCalendarGrid.vue | 22 +++++++
.../range-calendar/RangeCalendarGridBody.vue | 12 ++++
.../range-calendar/RangeCalendarGridHead.vue | 13 +++++
.../range-calendar/RangeCalendarGridRow.vue | 19 ++++++
.../range-calendar/RangeCalendarHeadCell.vue | 19 ++++++
.../ui/range-calendar/RangeCalendarHeader.vue | 19 ++++++
.../range-calendar/RangeCalendarHeading.vue | 29 ++++++++++
.../RangeCalendarNextButton.vue | 30 ++++++++++
.../RangeCalendarPrevButton.vue | 30 ++++++++++
src/components/ui/range-calendar/index.ts | 12 ++++
13 files changed, 325 insertions(+)
create mode 100644 src/components/ui/range-calendar/RangeCalendar.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarCell.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarCellTrigger.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarGrid.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarGridBody.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarGridHead.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarGridRow.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarHeadCell.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarHeader.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarHeading.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarNextButton.vue
create mode 100644 src/components/ui/range-calendar/RangeCalendarPrevButton.vue
create mode 100644 src/components/ui/range-calendar/index.ts
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ day }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ headingValue }}
+
+
+
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"
--
2.55.0
From 7dbabfaa81e59a264bfa2e0ce8f22aa2f6717845 Mon Sep 17 00:00:00 2001
From: Padreug
Date: Wed, 16 Sep 2026 11:53:22 +0200
Subject: [PATCH 2/4] feat(chatelet): availability calendar with Airbnb-style
stay picking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../chatelet/components/StayDatePicker.vue | 260 ++++++++++++++++++
.../composables/useRoomUnavailability.ts | 48 ++++
src/modules/chatelet/lib/unavailable.spec.ts | 50 ++++
src/modules/chatelet/lib/unavailable.ts | 49 ++++
.../chatelet/services/ChateletApiService.ts | 22 +-
src/modules/chatelet/types/room.ts | 14 +
.../chatelet/views/ChateletDetailPage.vue | 114 ++++----
7 files changed, 502 insertions(+), 55 deletions(-)
create mode 100644 src/modules/chatelet/components/StayDatePicker.vue
create mode 100644 src/modules/chatelet/composables/useRoomUnavailability.ts
create mode 100644 src/modules/chatelet/lib/unavailable.spec.ts
create mode 100644 src/modules/chatelet/lib/unavailable.ts
diff --git a/src/modules/chatelet/components/StayDatePicker.vue b/src/modules/chatelet/components/StayDatePicker.vue
new file mode 100644
index 0000000..3c2ee0e
--- /dev/null
+++ b/src/modules/chatelet/components/StayDatePicker.vue
@@ -0,0 +1,260 @@
+
+
+
+
+
+
+
+
{{ title }}
+
{{ subtitle }}
+
+
+
+
Check-in
+
+
+ {{ checkIn ? pretty(checkIn) : 'Add date' }}
+
+
+
+
+
+
Check-out
+
+
+ {{ checkOut ? pretty(checkOut) : 'Add date' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Unavailable
+
+
+
+
+
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/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..36e74f3 100644
--- a/src/modules/chatelet/services/ChateletApiService.ts
+++ b/src/modules/chatelet/services/ChateletApiService.ts
@@ -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 {
+ 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,
diff --git a/src/modules/chatelet/types/room.ts b/src/modules/chatelet/types/room.ts
index 6800d6a..b49ebef 100644
--- a/src/modules/chatelet/types/room.ts
+++ b/src/modules/chatelet/types/room.ts
@@ -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[]
+}
diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue
index ae43195..aa71891 100644
--- a/src/modules/chatelet/views/ChateletDetailPage.vue
+++ b/src/modules/chatelet/views/ChateletDetailPage.vue
@@ -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(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) => {
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ Could not load the availability calendar — you can still pick dates and check them.
+
-
+
+
+ Checking availability…
+
+
+
-
- Available for {{ result.nights }} night(s) —
- {{ result.quote_sat }} sats
-
- (≈ {{ result.quote_fiat }} {{ result.currency }})
-
-
+
+
+ {{ room.price_amount }} {{ room.price_currency }} × {{ result.nights }} night{{ result.nights === 1 ? '' : 's' }}
+ {{ result.quote_fiat }} {{ result.currency }}
+
+
+ Total
+ {{ formatSats(result.quote_sat) }}
+
+
--
2.55.0
From fb0478c02835ad45b2923a53fb7089cfb9ffdf1a Mon Sep 17 00:00:00 2001
From: Padreug
Date: Wed, 16 Sep 2026 12:17:43 +0200
Subject: [PATCH 3/4] feat(chatelet): photo gallery and house rules on the room
page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Swap the single hero image for the shared ImageViewer (thumbnails,
lightbox and cycle controls when a room has more than one photo, as the
marketplace product page does) and add a 'Things to know' card: check-in
from / check-out by, guest cap, minimum stay and the cancellation policy.
The rules come from the room owner's operator settings (chatelet ≥ v0.5.0)
so the card only renders when the extension sends them.
Co-Authored-By: Claude Fable 5.1
---
src/modules/chatelet/types/room.ts | 11 +++++
.../chatelet/views/ChateletDetailPage.vue | 45 +++++++++++++++++--
2 files changed, 52 insertions(+), 4 deletions(-)
diff --git a/src/modules/chatelet/types/room.ts b/src/modules/chatelet/types/room.ts
index b49ebef..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 {
diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue
index aa71891..612d425 100644
--- a/src/modules/chatelet/views/ChateletDetailPage.vue
+++ b/src/modules/chatelet/views/ChateletDetailPage.vue
@@ -3,10 +3,11 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { watchDebounced } from '@vueuse/core'
-import { MapPin, Users } from 'lucide-vue-next'
+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 { ImageViewer } from '@/components/ui/image'
import { formatSats } from '@/lib/utils/formatting'
import { useAuth } from '@/composables/useAuthService'
import { useChatelet } from '../composables/useChatelet'
@@ -129,11 +130,18 @@ watch(showBooking, (open) => {
Room not found.
-
![]()
+
@@ -161,6 +169,35 @@ 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 }}
+
+
+
+
+
Date: Wed, 16 Sep 2026 12:19:56 +0200
Subject: [PATCH 4/4] feat(chatelet): My bookings page and bottom navigation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/my-bookings (auth-required, mirrors events' /my-tickets) lists the
signed-in guest's stays from the extension's new GET /bookings/mine
(chatelet ≥ v0.5.0): upcoming and past, room title and photo joined from
the public room list, dates and nights, guest count, amount, and a status
badge — unpaid holds show until when the hold lasts. The standalone app
gains its first bottom tabs, Rooms and My bookings, the latter ghosted
with a log-in toast when signed out; the booking confirmation links there.
Co-Authored-By: Claude Fable 5.1
---
src/chatelet-app/App.vue | 37 +++++-
.../chatelet/components/BookRoomDialog.vue | 12 +-
.../chatelet/composables/useMyBookings.ts | 66 ++++++++++
src/modules/chatelet/index.ts | 10 +-
.../chatelet/services/ChateletApiService.ts | 14 +-
src/modules/chatelet/types/booking.ts | 7 +
src/modules/chatelet/views/MyBookingsPage.vue | 121 ++++++++++++++++++
7 files changed, 259 insertions(+), 8 deletions(-)
create mode 100644 src/modules/chatelet/composables/useMyBookings.ts
create mode 100644 src/modules/chatelet/views/MyBookingsPage.vue
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/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 @@
+
+
+
+
My bookings
+
+
+ Loading…
+
+
{{ error }}
+
+
+
+
+ {{ group.title }}
+
+
+
+
+
+
+
+
+
+
{{ roomTitle(b.room_id) }}
+
{{ statusBadge(b).label }}
+
+ {{ stay(b) }}
+
+
+ {{ b.num_guests }} guest{{ b.num_guests === 1 ? '' : 's' }}
+
+
+ {{ formatSats(b.deposit_sat) }}
+
+ (≈ {{ b.price_fiat }} {{ b.currency }})
+
+
+
+
+
+
+
+
+
+
+
+
No bookings yet.
+
+
+
+
+
--
2.55.0