Compare commits
No commits in common. "42d7eaaaddcac92e78ee59f9a01bad20fcb2f3eb" and "8a6a8c08beb41e8b321de949896c7f85cff900c3" have entirely different histories.
42d7eaaadd
...
8a6a8c08be
26 changed files with 66 additions and 1137 deletions
|
|
@ -1,40 +1,13 @@
|
|||
<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 type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
// Chatelet's in-page navigation is route-driven (browse → detail); the shell
|
||||
// only contributes the always-on Profile entry on the bottom row.
|
||||
const tabs: BottomTab[] = []
|
||||
|
||||
// Rooms (browse → detail) and the guest's own bookings. My bookings is
|
||||
// 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 + '/')
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
|
|
@ -48,12 +47,6 @@ const emit = defineEmits<Emits>()
|
|||
|
||||
const { currentUser } = useAuth()
|
||||
const flow = useBookingFlow()
|
||||
const router = useRouter()
|
||||
|
||||
function viewMyBookings() {
|
||||
emit('update:isOpen', false)
|
||||
router.push('/my-bookings')
|
||||
}
|
||||
|
||||
// --- Guest details form --------------------------------------------------
|
||||
|
||||
|
|
@ -353,10 +346,7 @@ watch(
|
|||
<p class="text-xs text-muted-foreground">
|
||||
Check-in details arrive as a private Nostr message from the host.
|
||||
</p>
|
||||
<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>
|
||||
<Button class="w-full" @click="emit('update:isOpen', false)">Done</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,260 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
|
|
@ -31,14 +31,6 @@ 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) => {
|
||||
|
|
@ -60,4 +52,4 @@ export const chateletModule = createModulePlugin({
|
|||
export default chateletModule
|
||||
|
||||
export type { Room, AvailabilityResult } from './types/room'
|
||||
export type { Booking, BookingQuote, BookingRequest, BookingStatus, GuestBooking } from './types/booking'
|
||||
export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking'
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { DateRange } from '../types/room'
|
||||
|
||||
/** YYYY-MM-DD → YYYY-MM-DD, `days` later. Date-only math, no timezone. */
|
||||
export function addDays(ymd: string, days: number): string {
|
||||
const d = new Date(ymd + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Nights between two YYYY-MM-DD dates (check_out exclusive). */
|
||||
export function nightsBetween(checkIn: string, checkOut: string): number {
|
||||
const a = Date.UTC(+checkIn.slice(0, 4), +checkIn.slice(5, 7) - 1, +checkIn.slice(8, 10))
|
||||
const b = Date.UTC(+checkOut.slice(0, 4), +checkOut.slice(5, 7) - 1, +checkOut.slice(8, 10))
|
||||
return Math.round((b - a) / 86_400_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand half-open [start, end) spans into the sorted, de-duplicated list of
|
||||
* occupied nights. A year of a busy room is a few hundred strings — cheap to
|
||||
* hold and to binary-search.
|
||||
*/
|
||||
export function expandRanges(ranges: DateRange[]): string[] {
|
||||
const out = new Set<string>()
|
||||
for (const r of ranges) {
|
||||
for (let d = r.start; d < r.end; d = addDays(d, 1)) out.add(d)
|
||||
}
|
||||
return [...out].sort()
|
||||
}
|
||||
|
||||
/** First occupied night ≥ `ymd`, or undefined if none in the loaded window. */
|
||||
export function nextUnavailableOnOrAfter(nights: string[], ymd: string): string | undefined {
|
||||
let lo = 0
|
||||
let hi = nights.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (nights[mid] < ymd) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return nights[lo]
|
||||
}
|
||||
|
||||
/** True when every night of [checkIn, checkOut) is free. */
|
||||
export function stayIsFree(nightSet: Set<string>, checkIn: string, checkOut: string): boolean {
|
||||
if (!(checkIn < checkOut)) return false
|
||||
for (let d = checkIn; d < checkOut; d = addDays(d, 1)) {
|
||||
if (nightSet.has(d)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AvailabilityResult, Room, UnavailableRanges } from '../types/room'
|
||||
import type { BookingQuote, BookingRequest, GuestBooking, PublicBooking } from '../types/booking'
|
||||
import type { AvailabilityResult, Room } from '../types/room'
|
||||
import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking'
|
||||
|
||||
export interface ChateletApiConfig {
|
||||
baseUrl: string
|
||||
|
|
@ -30,26 +30,6 @@ export class ChateletApiService {
|
|||
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Nights a guest cannot book over a window (default today → +365 d): live
|
||||
* holds, confirmed stays and manual blocks merged into anonymous spans.
|
||||
* Keyless, like the room read it hangs off.
|
||||
*/
|
||||
async getUnavailableRanges(
|
||||
roomId: string,
|
||||
start?: string,
|
||||
end?: string,
|
||||
): Promise<UnavailableRanges> {
|
||||
const q = new URLSearchParams()
|
||||
if (start) q.set('start', start)
|
||||
if (end) q.set('end', end)
|
||||
const qs = q.toString()
|
||||
return this.request(
|
||||
`/chatelet/api/v1/public/rooms/${roomId}/unavailable${qs ? `?${qs}` : ''}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
}
|
||||
|
||||
async checkAvailability(
|
||||
roomId: string,
|
||||
checkIn: string,
|
||||
|
|
@ -84,18 +64,6 @@ 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<GuestBooking[]> {
|
||||
return this.request('/chatelet/api/v1/bookings/mine', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
}
|
||||
|
||||
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
||||
const headers: Record<string, string> = {
|
||||
accept: 'application/json',
|
||||
|
|
|
|||
|
|
@ -70,13 +70,6 @@ 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<Booking, 'payment_hash'>
|
||||
|
||||
/** Statuses that mean the hold is gone and the invoice will never confirm. */
|
||||
export const LOST_BOOKING_STATUSES: ReadonlySet<string> = new Set([
|
||||
'expired',
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@
|
|||
// 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
|
||||
|
|
@ -26,10 +19,6 @@ 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 {
|
||||
|
|
@ -42,17 +31,3 @@ export interface AvailabilityResult {
|
|||
quote_fiat: number | null
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
/** Half-open [start, end) span of occupied nights; `end` is the morning the room frees up. */
|
||||
export interface DateRange {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
/** `GET /chatelet/api/v1/public/rooms/{id}/unavailable` — the guest calendar feed. */
|
||||
export interface UnavailableRanges {
|
||||
room_id: string
|
||||
start: string
|
||||
end: string
|
||||
ranges: DateRange[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,16 @@
|
|||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { Clock, MapPin, Moon, Users } from 'lucide-vue-next'
|
||||
import { addDays, format } from 'date-fns'
|
||||
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 { ImageViewer } from '@/components/ui/image'
|
||||
import { formatSats } from '@/lib/utils/formatting'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||
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()
|
||||
|
|
@ -33,23 +30,25 @@ const showBooking = ref(false)
|
|||
* `result` being cleared once the dates are taken. */
|
||||
const bookingQuote = ref<AvailabilityResult | null>(null)
|
||||
|
||||
// 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)
|
||||
// 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'))
|
||||
|
||||
// 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], () => {
|
||||
// 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,
|
||||
)
|
||||
|
||||
watch(checkIn, () => {
|
||||
if (checkOut.value && checkOut.value < minCheckOut.value) checkOut.value = ''
|
||||
result.value = null
|
||||
})
|
||||
watch(checkOut, () => {
|
||||
result.value = null
|
||||
})
|
||||
watchDebounced(
|
||||
[checkIn, checkOut],
|
||||
([ci, co]) => {
|
||||
if (ci && co && room.value) void onCheck()
|
||||
},
|
||||
{ debounce: 150 },
|
||||
)
|
||||
|
||||
const YMD = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
|
|
@ -67,6 +66,7 @@ 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,16 +75,7 @@ async function onCheck() {
|
|||
checking.value = true
|
||||
result.value = null
|
||||
try {
|
||||
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()
|
||||
}
|
||||
result.value = await checkAvailability(room.value.id, checkIn.value, checkOut.value)
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Availability check failed')
|
||||
} finally {
|
||||
|
|
@ -111,12 +102,11 @@ function onBook() {
|
|||
showBooking.value = true
|
||||
}
|
||||
|
||||
/** The dates are taken now — drop the stale quote and grey them out. */
|
||||
/** The dates are taken now — drop the stale quote so the card resets. */
|
||||
function onBooked() {
|
||||
result.value = null
|
||||
checkIn.value = ''
|
||||
checkOut.value = ''
|
||||
void unavailability.refresh()
|
||||
}
|
||||
|
||||
watch(showBooking, (open) => {
|
||||
|
|
@ -130,18 +120,11 @@ watch(showBooking, (open) => {
|
|||
<div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Gallery: thumbnails + lightbox when there is more than one photo
|
||||
(same ImageViewer the marketplace product page uses). -->
|
||||
<ImageViewer
|
||||
<img
|
||||
v-if="room.images?.length"
|
||||
:images="room.images"
|
||||
:src="room.images[0]"
|
||||
:alt="room.title"
|
||||
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"
|
||||
class="w-full h-56 object-cover rounded-lg"
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
|
@ -169,66 +152,40 @@ watch(showBooking, (open) => {
|
|||
{{ room.description }}
|
||||
</p>
|
||||
|
||||
<!-- House rules come from the room owner's operator settings; older
|
||||
extensions don't send them, so the card is conditional. -->
|
||||
<Card v-if="room.house_rules">
|
||||
<CardContent class="p-4 space-y-2">
|
||||
<h2 class="font-semibold text-foreground">Things to know</h2>
|
||||
<ul class="text-sm text-muted-foreground space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<Clock class="h-4 w-4 shrink-0" />
|
||||
Check-in from {{ room.house_rules.checkin_time }} · check-out by
|
||||
{{ room.house_rules.checkout_time }}
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<Users class="h-4 w-4 shrink-0" />
|
||||
Up to {{ room.max_guests }} guest{{ room.max_guests === 1 ? '' : 's' }}
|
||||
</li>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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…
|
||||
<CardContent class="p-4 space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label>Check-in</Label>
|
||||
<DatePicker v-model="checkIn" :min="today" placeholder="Check-in" />
|
||||
</div>
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label>Check-out</Label>
|
||||
<DatePicker
|
||||
v-model="checkOut"
|
||||
:min="minCheckOut"
|
||||
:disabled="!checkIn"
|
||||
placeholder="Check-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="!checkIn || !checkOut || checking"
|
||||
@click="onCheck"
|
||||
>
|
||||
{{ checking ? 'Checking…' : 'Check availability' }}
|
||||
</Button>
|
||||
|
||||
<div v-if="result && !checking" class="text-sm">
|
||||
<div v-if="result" class="text-sm pt-1">
|
||||
<template v-if="result.available">
|
||||
<div class="rounded-lg bg-muted/50 p-3 space-y-1">
|
||||
<div class="flex justify-between text-muted-foreground">
|
||||
<span>{{ room.price_amount }} {{ room.price_currency }} × {{ result.nights }} night{{ result.nights === 1 ? '' : 's' }}</span>
|
||||
<span v-if="result.quote_fiat != null">{{ result.quote_fiat }} {{ result.currency }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between font-semibold text-foreground">
|
||||
<span>Total</span>
|
||||
<span v-if="result.quote_sat != null">{{ formatSats(result.quote_sat) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-foreground">
|
||||
Available for {{ result.nights }} night(s) —
|
||||
<span class="font-semibold">{{ result.quote_sat }} sats</span>
|
||||
<span v-if="result.quote_fiat" class="text-muted-foreground">
|
||||
(≈ {{ result.quote_fiat }} {{ result.currency }})
|
||||
</span>
|
||||
</p>
|
||||
<Button class="w-full mt-3" @click="onBook">
|
||||
{{ isAuthenticated ? 'Book' : 'Sign in to book' }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue