Compare commits

...

5 commits

Author SHA1 Message Date
42d7eaaadd Merge pull request 'feat(chatelet): stay calendar, room gallery + house rules, My bookings' (#156) from feat/chatelet-my-bookings into dev
Reviewed-on: #156
2026-09-21 09:12:06 +00:00
3c6954f144 feat(chatelet): My bookings page and bottom navigation
/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 <noreply@anthropic.com>
2026-09-16 12:19:56 +02:00
fb0478c028 feat(chatelet): photo gallery and house rules on the room page
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 <noreply@anthropic.com>
2026-09-16 12:17:43 +02:00
7dbabfaa81 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>
2026-09-16 11:53:22 +02:00
6ea94692a1 feat(ui): shadcn range-calendar primitive over reka-ui RangeCalendarRoot
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 <noreply@anthropic.com>
2026-09-16 11:53:22 +02:00
26 changed files with 1137 additions and 66 deletions

View file

@ -1,13 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BedDouble, CalendarCheck } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue' import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue' import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService'
// Chatelet's in-page navigation is route-driven (browse → detail); the shell const route = useRoute()
// only contributes the always-on Profile entry on the bottom row. const router = useRouter()
const tabs: BottomTab[] = [] const { isAuthenticated } = useAuth()
function isActive(_path: string): boolean { // Rooms (browse → detail) and the guest's own bookings. My bookings is
return false // auth-gated the same way events gates My tickets: ghosted when logged out,
// and a tap explains instead of bouncing to /login.
const tabs = computed<BottomTab[]>(() => [
{ name: 'Rooms', icon: BedDouble, path: '/chatelet' },
{
name: 'My bookings',
icon: CalendarCheck,
path: '/my-bookings',
onClick: () => {
if (!isAuthenticated.value) {
toast.info('Log in to see your bookings', {
action: { label: 'Log in', onClick: () => router.push('/login') },
})
return
}
router.push('/my-bookings')
},
disabled: !isAuthenticated.value,
},
])
function isActive(path: string): boolean {
return route.path === path || route.path.startsWith(path + '/')
} }
</script> </script>

View file

@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { RangeCalendarRootEmits, RangeCalendarRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNextButton, RangeCalendarPrevButton } from "."
const props = defineProps<RangeCalendarRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<RangeCalendarRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<RangeCalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<RangeCalendarHeader>
<RangeCalendarPrevButton />
<RangeCalendarHeading />
<RangeCalendarNextButton />
</RangeCalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<RangeCalendarGrid v-for="month in grid" :key="month.value.toString()">
<RangeCalendarGridHead>
<RangeCalendarGridRow>
<RangeCalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</RangeCalendarHeadCell>
</RangeCalendarGridRow>
</RangeCalendarGridHead>
<RangeCalendarGridBody>
<RangeCalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<RangeCalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<RangeCalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</RangeCalendarCell>
</RangeCalendarGridRow>
</RangeCalendarGridBody>
</RangeCalendarGrid>
</div>
</RangeCalendarRoot>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { RangeCalendarCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarCell
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarCell>
</template>

View file

@ -0,0 +1,40 @@
<script lang="ts" setup>
import type { RangeCalendarCellTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarCellTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Range interior: hover preview (highlighted) and committed selection
// share the accent tint; the two ends get the primary pill.
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[highlighted]:rounded-none',
'data-[selected]:bg-accent data-[selected]:text-accent-foreground data-[selected]:rounded-none data-[selected]:opacity-100',
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:rounded-md data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground',
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:rounded-md data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground',
// Disabled (before today, past the next occupied night, under min stay)
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-40 data-[disabled]:pointer-events-none',
// Unavailable: an occupied night — struck through, muted, not a target
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through data-[unavailable]:opacity-50 data-[unavailable]:pointer-events-none',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarCellTrigger>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { RangeCalendarGridProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarGrid, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarGridProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarGrid>
</template>

View file

@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { RangeCalendarGridBodyProps } from "reka-ui"
import { RangeCalendarGridBody } from "reka-ui"
const props = defineProps<RangeCalendarGridBodyProps>()
</script>
<template>
<RangeCalendarGridBody v-bind="props">
<slot />
</RangeCalendarGridBody>
</template>

View file

@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { RangeCalendarGridHeadProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { RangeCalendarGridHead } from "reka-ui"
const props = defineProps<RangeCalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<RangeCalendarGridHead v-bind="props">
<slot />
</RangeCalendarGridHead>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { RangeCalendarGridRowProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarGridRow, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</RangeCalendarGridRow>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { RangeCalendarHeadCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarHeadCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeadCell :class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
<slot />
</RangeCalendarHeadCell>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { RangeCalendarHeaderProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarHeader, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
<slot />
</RangeCalendarHeader>
</template>

View file

@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { RangeCalendarHeadingProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarHeading, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</RangeCalendarHeading>
</template>

View file

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { RangeCalendarNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import { RangeCalendarNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarNextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarNext
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</RangeCalendarNext>
</template>

View file

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { RangeCalendarPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeft } from "lucide-vue-next"
import { RangeCalendarPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarPrevProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarPrev
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</RangeCalendarPrev>
</template>

View file

@ -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"

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod' import * as z from 'zod'
@ -47,6 +48,12 @@ const emit = defineEmits<Emits>()
const { currentUser } = useAuth() const { currentUser } = useAuth()
const flow = useBookingFlow() const flow = useBookingFlow()
const router = useRouter()
function viewMyBookings() {
emit('update:isOpen', false)
router.push('/my-bookings')
}
// --- Guest details form -------------------------------------------------- // --- Guest details form --------------------------------------------------
@ -346,7 +353,10 @@ watch(
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Check-in details arrive as a private Nostr message from the host. Check-in details arrive as a private Nostr message from the host.
</p> </p>
<Button class="w-full" @click="emit('update:isOpen', false)">Done</Button> <div class="grid grid-cols-2 gap-2">
<Button variant="outline" @click="emit('update:isOpen', false)">Done</Button>
<Button @click="viewMyBookings">View my bookings</Button>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

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,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<ChateletApiService>(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 }
}

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

@ -31,6 +31,14 @@ export const chateletModule = createModulePlugin({
component: () => import('./views/ChateletDetailPage.vue'), component: () => import('./views/ChateletDetailPage.vue'),
meta: { title: 'Room', requiresAuth: false }, 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) => { onInstall: async (_app, options) => {
@ -52,4 +60,4 @@ export const chateletModule = createModulePlugin({
export default chateletModule export default chateletModule
export type { Room, AvailabilityResult } from './types/room' export type { Room, AvailabilityResult } from './types/room'
export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking' export type { Booking, BookingQuote, BookingRequest, BookingStatus, GuestBooking } from './types/booking'

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,5 +1,5 @@
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, GuestBooking, PublicBooking } from '../types/booking'
export interface ChateletApiConfig { export interface ChateletApiConfig {
baseUrl: string baseUrl: string
@ -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,
@ -64,6 +84,18 @@ export class ChateletApiService {
return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' }) 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<GuestBooking[]> {
return this.request('/chatelet/api/v1/bookings/mine', {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
}
private async request(path: string, init: RequestInit = {}): Promise<any> { private async request(path: string, init: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
accept: 'application/json', accept: 'application/json',

View file

@ -70,6 +70,13 @@ export type PublicBooking = Omit<
'guest_pubkey' | 'guest_contact' | 'payment_hash' '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<Booking, 'payment_hash'>
/** Statuses that mean the hold is gone and the invoice will never confirm. */ /** Statuses that mean the hold is gone and the invoice will never confirm. */
export const LOST_BOOKING_STATUSES: ReadonlySet<string> = new Set([ export const LOST_BOOKING_STATUSES: ReadonlySet<string> = new Set([
'expired', 'expired',

View file

@ -2,6 +2,13 @@
// Note: operator-private fields (wallet, checkin_instructions) are stripped // Note: operator-private fields (wallet, checkin_instructions) are stripped
// server-side by public_room_dict and never reach the client. // 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 { export interface Room {
id: string id: string
title: string title: string
@ -19,6 +26,10 @@ export interface Room {
listing_event_id: string | null listing_event_id: string | null
created_at: string created_at: string
updated_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 { export interface AvailabilityResult {
@ -31,3 +42,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,19 @@
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 { Clock, MapPin, Moon, 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 { ImageViewer } from '@/components/ui/image'
import DatePicker from '@/modules/base/components/DatePicker.vue' import { formatSats } from '@/lib/utils/formatting'
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 +33,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 +67,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 +75,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 +111,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) => {
@ -120,11 +130,18 @@ watch(showBooking, (open) => {
<div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div> <div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div>
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<img <!-- Gallery: thumbnails + lightbox when there is more than one photo
(same ImageViewer the marketplace product page uses). -->
<ImageViewer
v-if="room.images?.length" v-if="room.images?.length"
:src="room.images[0]" :images="room.images"
:alt="room.title" :alt="room.title"
class="w-full h-56 object-cover rounded-lg" container-class="aspect-[4/3] sm:aspect-video rounded-lg overflow-hidden bg-muted"
image-class="w-full h-full object-cover"
:show-thumbnails="room.images.length > 1"
:show-lightbox="true"
:show-badge="room.images.length > 1"
:show-cycle-controls="room.images.length > 1"
/> />
<div> <div>
@ -152,40 +169,66 @@ watch(showBooking, (open) => {
{{ room.description }} {{ room.description }}
</p> </p>
<Card> <!-- House rules come from the room owner's operator settings; older
<CardContent class="p-4 space-y-3"> extensions don't send them, so the card is conditional. -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <Card v-if="room.house_rules">
<div class="space-y-1 min-w-0"> <CardContent class="p-4 space-y-2">
<Label>Check-in</Label> <h2 class="font-semibold text-foreground">Things to know</h2>
<DatePicker v-model="checkIn" :min="today" placeholder="Check-in" /> <ul class="text-sm text-muted-foreground space-y-1">
</div> <li class="flex items-center gap-2">
<div class="space-y-1 min-w-0"> <Clock class="h-4 w-4 shrink-0" />
<Label>Check-out</Label> Check-in from {{ room.house_rules.checkin_time }} · check-out by
<DatePicker {{ room.house_rules.checkout_time }}
v-model="checkOut" </li>
:min="minCheckOut" <li class="flex items-center gap-2">
:disabled="!checkIn" <Users class="h-4 w-4 shrink-0" />
placeholder="Check-out" Up to {{ room.max_guests }} guest{{ room.max_guests === 1 ? '' : 's' }}
/> </li>
</div> <li class="flex items-center gap-2">
<Moon class="h-4 w-4 shrink-0" />
Minimum stay {{ room.min_nights }} night{{ room.min_nights === 1 ? '' : 's' }}
</li>
</ul>
<div v-if="room.house_rules.cancellation_policy" class="pt-1">
<p class="text-sm font-medium text-foreground">Cancellation policy</p>
<p class="text-sm text-muted-foreground whitespace-pre-line">
{{ room.house_rules.cancellation_policy }}
</p>
</div> </div>
<Button </CardContent>
class="w-full" </Card>
:disabled="!checkIn || !checkOut || checking"
@click="onCheck"
>
{{ checking ? 'Checking…' : 'Check availability' }}
</Button>
<div v-if="result" class="text-sm pt-1"> <Card>
<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="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>

View file

@ -0,0 +1,121 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { format } from 'date-fns'
import { CalendarCheck, ChevronRight } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { formatSats } from '@/lib/utils/formatting'
import { useChatelet } from '../composables/useChatelet'
import { useMyBookings } from '../composables/useMyBookings'
import type { GuestBooking } from '../types/booking'
const { rooms, loadRooms } = useChatelet()
const { upcoming, past, isLoading, error, refresh, roomTitle, roomImage } = useMyBookings()
onMounted(async () => {
if (!rooms.value.length) void loadRooms()
await refresh()
})
function stay(b: GuestBooking): string {
const ci = format(new Date(b.check_in + 'T00:00:00'), 'EEE, MMM d')
const co = format(new Date(b.check_out + 'T00:00:00'), 'EEE, MMM d, yyyy')
return `${ci} → ${co} · ${b.nights} night${b.nights === 1 ? '' : 's'}`
}
function holdUntil(b: GuestBooking): string {
if (!b.expires_at) return ''
const d = new Date(b.expires_at)
return isNaN(d.getTime()) ? '' : format(d, 'p')
}
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'
function statusBadge(b: GuestBooking): { label: string; variant: BadgeVariant } {
switch (b.status) {
case 'confirmed':
return { label: 'Confirmed', variant: 'default' }
case 'checked_in':
return { label: 'Checked in', variant: 'default' }
case 'completed':
return { label: 'Completed', variant: 'outline' }
case 'held':
case 'awaiting_payment':
return {
label: holdUntil(b) ? `Unpaid · hold until ${holdUntil(b)}` : 'Unpaid',
variant: 'secondary',
}
case 'expired':
return { label: 'Expired', variant: 'outline' }
case 'declined':
return { label: 'Declined', variant: 'destructive' }
case 'cancelled':
return { label: 'Cancelled', variant: 'outline' }
default:
return { label: b.status, variant: 'outline' }
}
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-2xl space-y-6">
<h1 class="text-2xl font-bold text-foreground">My bookings</h1>
<div v-if="isLoading && !upcoming.length && !past.length" class="text-muted-foreground py-8">
Loading…
</div>
<p v-else-if="error" class="text-sm text-destructive">{{ error }}</p>
<template v-else>
<section v-for="group in [
{ title: 'Upcoming', items: upcoming },
{ title: 'Past', items: past },
]" :key="group.title" class="space-y-3">
<h2 v-if="group.items.length" class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{{ group.title }}
</h2>
<Card v-for="b in group.items" :key="b.id" class="overflow-hidden">
<RouterLink :to="`/chatelet/${b.room_id}`" class="flex gap-3">
<img
v-if="roomImage(b.room_id)"
:src="roomImage(b.room_id)"
:alt="roomTitle(b.room_id)"
class="h-24 w-24 shrink-0 object-cover"
/>
<div v-else class="h-24 w-24 shrink-0 bg-muted flex items-center justify-center">
<CalendarCheck class="h-6 w-6 text-muted-foreground" />
</div>
<CardContent class="p-3 pl-0 flex-1 min-w-0 space-y-1">
<div class="flex items-start justify-between gap-2">
<p class="font-semibold text-foreground truncate">{{ roomTitle(b.room_id) }}</p>
<Badge :variant="statusBadge(b).variant" class="shrink-0">{{ statusBadge(b).label }}</Badge>
</div>
<p class="text-sm text-muted-foreground">{{ stay(b) }}</p>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">
{{ b.num_guests }} guest{{ b.num_guests === 1 ? '' : 's' }}
</span>
<span class="font-medium text-foreground">
{{ formatSats(b.deposit_sat) }}
<span v-if="b.currency.toLowerCase() !== 'sat'" class="text-muted-foreground font-normal">
(≈ {{ b.price_fiat }} {{ b.currency }})
</span>
</span>
</div>
</CardContent>
<ChevronRight class="h-4 w-4 self-center mr-3 text-muted-foreground shrink-0" />
</RouterLink>
</Card>
</section>
<div v-if="!upcoming.length && !past.length" class="text-center py-12 space-y-3">
<CalendarCheck class="mx-auto h-10 w-10 text-muted-foreground" />
<p class="text-muted-foreground">No bookings yet.</p>
<Button as-child variant="outline">
<RouterLink to="/chatelet">Browse rooms</RouterLink>
</Button>
</div>
</template>
</div>
</template>