Merge pull request 'feat(chatelet): stay calendar, room gallery + house rules, My bookings' (#156) from feat/chatelet-my-bookings into dev
Reviewed-on: #156
This commit is contained in:
commit
42d7eaaadd
26 changed files with 1137 additions and 66 deletions
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
58
src/components/ui/range-calendar/RangeCalendar.vue
Normal file
58
src/components/ui/range-calendar/RangeCalendar.vue
Normal 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>
|
||||||
22
src/components/ui/range-calendar/RangeCalendarCell.vue
Normal file
22
src/components/ui/range-calendar/RangeCalendarCell.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
22
src/components/ui/range-calendar/RangeCalendarGrid.vue
Normal file
22
src/components/ui/range-calendar/RangeCalendarGrid.vue
Normal 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>
|
||||||
12
src/components/ui/range-calendar/RangeCalendarGridBody.vue
Normal file
12
src/components/ui/range-calendar/RangeCalendarGridBody.vue
Normal 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>
|
||||||
13
src/components/ui/range-calendar/RangeCalendarGridHead.vue
Normal file
13
src/components/ui/range-calendar/RangeCalendarGridHead.vue
Normal 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>
|
||||||
19
src/components/ui/range-calendar/RangeCalendarGridRow.vue
Normal file
19
src/components/ui/range-calendar/RangeCalendarGridRow.vue
Normal 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>
|
||||||
19
src/components/ui/range-calendar/RangeCalendarHeadCell.vue
Normal file
19
src/components/ui/range-calendar/RangeCalendarHeadCell.vue
Normal 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>
|
||||||
19
src/components/ui/range-calendar/RangeCalendarHeader.vue
Normal file
19
src/components/ui/range-calendar/RangeCalendarHeader.vue
Normal 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>
|
||||||
29
src/components/ui/range-calendar/RangeCalendarHeading.vue
Normal file
29
src/components/ui/range-calendar/RangeCalendarHeading.vue
Normal 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>
|
||||||
30
src/components/ui/range-calendar/RangeCalendarNextButton.vue
Normal file
30
src/components/ui/range-calendar/RangeCalendarNextButton.vue
Normal 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>
|
||||||
30
src/components/ui/range-calendar/RangeCalendarPrevButton.vue
Normal file
30
src/components/ui/range-calendar/RangeCalendarPrevButton.vue
Normal 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>
|
||||||
12
src/components/ui/range-calendar/index.ts
Normal file
12
src/components/ui/range-calendar/index.ts
Normal 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"
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
260
src/modules/chatelet/components/StayDatePicker.vue
Normal file
260
src/modules/chatelet/components/StayDatePicker.vue
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, shallowRef, watch } from 'vue'
|
||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
CalendarDate,
|
||||||
|
getLocalTimeZone,
|
||||||
|
parseDate,
|
||||||
|
today,
|
||||||
|
type DateValue,
|
||||||
|
} from '@internationalized/date'
|
||||||
|
import type { DateRange as RekaDateRange } from 'reka-ui'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { RangeCalendar } from '@/components/ui/range-calendar'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { addDays, nextUnavailableOnOrAfter, nightsBetween, stayIsFree } from '../lib/unavailable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Airbnb-style stay picker: one range calendar where occupied nights are
|
||||||
|
* struck through, check-out may land on the morning another guest arrives,
|
||||||
|
* and a stay can never straddle an occupied night.
|
||||||
|
*
|
||||||
|
* reka-ui only guards clicks with `isDateDisabled` and, with contiguous
|
||||||
|
* ranges, hides the selected styling on `isDateUnavailable` days — so the
|
||||||
|
* matchers below are selection-aware: once a check-in is chosen, the first
|
||||||
|
* occupied night after it stops being "unavailable" (it is a legal
|
||||||
|
* check-out) and every later day is disabled. Both matchers close over refs,
|
||||||
|
* because reka reads them once at setup.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** YYYY-MM-DD or '' */
|
||||||
|
checkIn: string
|
||||||
|
checkOut: string
|
||||||
|
/** Sorted occupied nights (YYYY-MM-DD) and the same as a Set. */
|
||||||
|
nights: string[]
|
||||||
|
nightSet: Set<string>
|
||||||
|
minNights: number
|
||||||
|
/** Last selectable day, YYYY-MM-DD (the loaded window end). */
|
||||||
|
maxDate?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:checkIn': [value: string]
|
||||||
|
'update:checkOut': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isWide = useMediaQuery('(min-width: 640px)')
|
||||||
|
const todayYmd = today(getLocalTimeZone()).toString()
|
||||||
|
|
||||||
|
function toYmd(d: DateValue | undefined): string {
|
||||||
|
return d ? new CalendarDate(d.year, d.month, d.day).toString() : ''
|
||||||
|
}
|
||||||
|
function fromYmd(ymd: string): CalendarDate | undefined {
|
||||||
|
if (!ymd) return undefined
|
||||||
|
try {
|
||||||
|
return parseDate(ymd)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- selection state -------------------------------------------------------
|
||||||
|
|
||||||
|
/** The in-progress check-in while the guest is still picking a check-out. */
|
||||||
|
const pendingStart = ref<string>('')
|
||||||
|
const bothSet = computed(() => Boolean(props.checkIn && props.checkOut))
|
||||||
|
const activeStart = computed(() => (bothSet.value ? '' : pendingStart.value || props.checkIn))
|
||||||
|
|
||||||
|
/** First occupied night after the chosen check-in: the last legal check-out. */
|
||||||
|
const boundary = computed(() =>
|
||||||
|
activeStart.value
|
||||||
|
? nextUnavailableOnOrAfter(props.nights, addDays(activeStart.value, 1))
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
// shallowRef: DateValue instances must not be made deeply reactive (their
|
||||||
|
// private fields break reka's typings and identity checks).
|
||||||
|
const model = shallowRef<RekaDateRange>({ start: fromYmd(props.checkIn), end: fromYmd(props.checkOut) })
|
||||||
|
watch(
|
||||||
|
() => [props.checkIn, props.checkOut] as const,
|
||||||
|
([ci, co]) => {
|
||||||
|
model.value = { start: fromYmd(ci), end: fromYmd(co) }
|
||||||
|
if (ci && co) pendingStart.value = ''
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const placeholder = shallowRef<DateValue>(fromYmd(props.checkIn) ?? today(getLocalTimeZone()))
|
||||||
|
|
||||||
|
const minValue = today(getLocalTimeZone())
|
||||||
|
const maxValue = computed(() => (props.maxDate ? fromYmd(props.maxDate) : undefined))
|
||||||
|
|
||||||
|
// --- matchers (stable functions over reactive state) ------------------------
|
||||||
|
|
||||||
|
function isDateUnavailable(d: DateValue): boolean {
|
||||||
|
const y = toYmd(d)
|
||||||
|
if (!props.nightSet.has(y)) return false
|
||||||
|
// The boundary night is a legal check-out, and a committed check-out that
|
||||||
|
// sits on an occupied night must keep rendering as selected.
|
||||||
|
if (y === boundary.value || (bothSet.value && y === props.checkOut)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateDisabled(d: DateValue): boolean {
|
||||||
|
const y = toYmd(d)
|
||||||
|
if (y < todayYmd) return true
|
||||||
|
const start = activeStart.value
|
||||||
|
if (!start) {
|
||||||
|
// Picking a check-in: occupied nights are out; everything else is fair.
|
||||||
|
return props.nightSet.has(y) && !(bothSet.value && y === props.checkOut)
|
||||||
|
}
|
||||||
|
if (y === start) return false
|
||||||
|
if (y > start) {
|
||||||
|
if (y < addDays(start, props.minNights)) return true
|
||||||
|
if (boundary.value && y > boundary.value) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Clicking before the chosen check-in restarts the range there.
|
||||||
|
return props.nightSet.has(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- events -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function onStartValue(v: DateValue | undefined) {
|
||||||
|
pendingStart.value = toYmd(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModel(v: RekaDateRange | null) {
|
||||||
|
const ci = toYmd(v?.start)
|
||||||
|
const co = toYmd(v?.end)
|
||||||
|
if (ci && co) {
|
||||||
|
const ok =
|
||||||
|
stayIsFree(props.nightSet, ci, co) && nightsBetween(ci, co) >= props.minNights
|
||||||
|
if (!ok) {
|
||||||
|
// Defensive: reka let a bad range through — keep the check-in, redo the end.
|
||||||
|
pendingStart.value = ci
|
||||||
|
emit('update:checkIn', ci)
|
||||||
|
emit('update:checkOut', '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingStart.value = ''
|
||||||
|
emit('update:checkIn', ci)
|
||||||
|
emit('update:checkOut', co)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ci && !co) {
|
||||||
|
pendingStart.value = ci
|
||||||
|
emit('update:checkIn', ci)
|
||||||
|
emit('update:checkOut', '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingStart.value = ''
|
||||||
|
emit('update:checkIn', '')
|
||||||
|
emit('update:checkOut', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
onModel(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCheckOut() {
|
||||||
|
if (!props.checkIn) return
|
||||||
|
onModel({ start: fromYmd(props.checkIn), end: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- display ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function pretty(ymd: string): string {
|
||||||
|
return ymd ? format(new Date(ymd + 'T00:00:00'), 'MMM d, yyyy') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const nights = computed(() => (bothSet.value ? nightsBetween(props.checkIn, props.checkOut) : 0))
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (bothSet.value) return `${nights.value} night${nights.value === 1 ? '' : 's'}`
|
||||||
|
if (props.checkIn) return 'Select check-out date'
|
||||||
|
return 'Select check-in date'
|
||||||
|
})
|
||||||
|
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (bothSet.value) return `${pretty(props.checkIn)} – ${pretty(props.checkOut)}`
|
||||||
|
if (props.checkIn) return `Minimum stay ${props.minNights} night${props.minNights === 1 ? '' : 's'}`
|
||||||
|
return 'Add your travel dates for exact pricing'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Summary header + the two labelled date boxes -->
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-foreground">{{ title }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 rounded-lg border border-input text-left">
|
||||||
|
<div
|
||||||
|
:class="cn('px-3 py-2', !checkIn && 'ring-2 ring-ring rounded-l-lg')"
|
||||||
|
>
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-in</p>
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<span :class="cn('text-sm', checkIn ? 'text-foreground' : 'text-muted-foreground')">
|
||||||
|
{{ checkIn ? pretty(checkIn) : 'Add date' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="checkIn"
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm opacity-60 hover:opacity-100"
|
||||||
|
aria-label="Clear check-in"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="cn('border-l border-input px-3 py-2', checkIn && !checkOut && 'ring-2 ring-ring rounded-r-lg')"
|
||||||
|
>
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-out</p>
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<span :class="cn('text-sm', checkOut ? 'text-foreground' : 'text-muted-foreground')">
|
||||||
|
{{ checkOut ? pretty(checkOut) : 'Add date' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="checkOut"
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm opacity-60 hover:opacity-100"
|
||||||
|
aria-label="Clear check-out"
|
||||||
|
@click="clearCheckOut"
|
||||||
|
>
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RangeCalendar
|
||||||
|
:model-value="model"
|
||||||
|
v-model:placeholder="placeholder"
|
||||||
|
:number-of-months="isWide ? 2 : 1"
|
||||||
|
paged-navigation
|
||||||
|
:week-starts-on="1"
|
||||||
|
:min-value="minValue"
|
||||||
|
:max-value="maxValue"
|
||||||
|
:is-date-unavailable="isDateUnavailable"
|
||||||
|
:is-date-disabled="isDateDisabled"
|
||||||
|
class="mx-auto w-fit p-0"
|
||||||
|
@update:model-value="onModel"
|
||||||
|
@update:start-value="onStartValue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="inline-block h-3 w-3 rounded-sm bg-muted line-through opacity-70"></span>
|
||||||
|
Unavailable
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" :disabled="!checkIn" @click="clear">Clear dates</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
66
src/modules/chatelet/composables/useMyBookings.ts
Normal file
66
src/modules/chatelet/composables/useMyBookings.ts
Normal 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 }
|
||||||
|
}
|
||||||
48
src/modules/chatelet/composables/useRoomUnavailability.ts
Normal file
48
src/modules/chatelet/composables/useRoomUnavailability.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { computed, ref, watch, type Ref } from 'vue'
|
||||||
|
import { getLocalTimeZone, today } from '@internationalized/date'
|
||||||
|
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
||||||
|
import type { ChateletApiService } from '../services/ChateletApiService'
|
||||||
|
import { addDays, expandRanges } from '../lib/unavailable'
|
||||||
|
|
||||||
|
const WINDOW_DAYS = 365
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occupied nights of one room for the calendar. One fetch of a 12-month
|
||||||
|
* window per room — a few spans, cheap — and the calendar's `maxValue` is
|
||||||
|
* pinned to the window end, so no refetch on month navigation. `refresh()`
|
||||||
|
* after a booking lands, or when a quote comes back "not available" for a
|
||||||
|
* stay the local set thought free (someone else's hold appeared).
|
||||||
|
*/
|
||||||
|
export function useRoomUnavailability(roomId: Ref<string | undefined>) {
|
||||||
|
// DI, never a direct import (workspace rule).
|
||||||
|
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
||||||
|
|
||||||
|
const nights = ref<string[]>([])
|
||||||
|
const windowStart = ref(today(getLocalTimeZone()).toString())
|
||||||
|
const windowEnd = ref(addDays(windowStart.value, WINDOW_DAYS))
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const nightSet = computed(() => new Set(nights.value))
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
if (!api || !roomId.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const start = today(getLocalTimeZone()).toString()
|
||||||
|
const res = await api.getUnavailableRanges(roomId.value, start, addDays(start, WINDOW_DAYS))
|
||||||
|
windowStart.value = res.start
|
||||||
|
windowEnd.value = res.end
|
||||||
|
nights.value = expandRanges(res.ranges)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Could not load availability'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(roomId, () => void refresh(), { immediate: true })
|
||||||
|
|
||||||
|
return { nights, nightSet, windowStart, windowEnd, isLoading, error, refresh }
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
50
src/modules/chatelet/lib/unavailable.spec.ts
Normal file
50
src/modules/chatelet/lib/unavailable.spec.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
expandRanges,
|
||||||
|
nextUnavailableOnOrAfter,
|
||||||
|
nightsBetween,
|
||||||
|
stayIsFree,
|
||||||
|
} from './unavailable'
|
||||||
|
|
||||||
|
describe('unavailable-night helpers', () => {
|
||||||
|
it('addDays crosses month and year boundaries', () => {
|
||||||
|
expect(addDays('2026-10-31', 1)).toBe('2026-11-01')
|
||||||
|
expect(addDays('2026-12-31', 1)).toBe('2027-01-01')
|
||||||
|
expect(addDays('2026-03-01', -1)).toBe('2026-02-28')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nightsBetween is check_out-exclusive', () => {
|
||||||
|
expect(nightsBetween('2026-10-05', '2026-10-07')).toBe(2)
|
||||||
|
expect(nightsBetween('2026-10-05', '2026-10-05')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expandRanges yields sorted, de-duplicated occupied nights (end exclusive)', () => {
|
||||||
|
const nights = expandRanges([
|
||||||
|
{ start: '2026-10-10', end: '2026-10-12' },
|
||||||
|
{ start: '2026-10-05', end: '2026-10-07' },
|
||||||
|
{ start: '2026-10-11', end: '2026-10-13' },
|
||||||
|
])
|
||||||
|
expect(nights).toEqual(['2026-10-05', '2026-10-06', '2026-10-10', '2026-10-11', '2026-10-12'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nextUnavailableOnOrAfter finds the boundary night', () => {
|
||||||
|
const nights = ['2026-10-05', '2026-10-06', '2026-10-10']
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-01')).toBe('2026-10-05')
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-05')).toBe('2026-10-05')
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-07')).toBe('2026-10-10')
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-11')).toBeUndefined()
|
||||||
|
expect(nextUnavailableOnOrAfter([], '2026-10-11')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stayIsFree honours half-open semantics', () => {
|
||||||
|
const set = new Set(['2026-10-05', '2026-10-06'])
|
||||||
|
// checking out the morning the other guest checks in is fine
|
||||||
|
expect(stayIsFree(set, '2026-10-03', '2026-10-05')).toBe(true)
|
||||||
|
// checking in the morning they leave is fine too
|
||||||
|
expect(stayIsFree(set, '2026-10-07', '2026-10-09')).toBe(true)
|
||||||
|
// spanning an occupied night is not
|
||||||
|
expect(stayIsFree(set, '2026-10-04', '2026-10-06')).toBe(false)
|
||||||
|
expect(stayIsFree(set, '2026-10-06', '2026-10-06')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
49
src/modules/chatelet/lib/unavailable.ts
Normal file
49
src/modules/chatelet/lib/unavailable.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { DateRange } from '../types/room'
|
||||||
|
|
||||||
|
/** YYYY-MM-DD → YYYY-MM-DD, `days` later. Date-only math, no timezone. */
|
||||||
|
export function addDays(ymd: string, days: number): string {
|
||||||
|
const d = new Date(ymd + 'T00:00:00Z')
|
||||||
|
d.setUTCDate(d.getUTCDate() + days)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nights between two YYYY-MM-DD dates (check_out exclusive). */
|
||||||
|
export function nightsBetween(checkIn: string, checkOut: string): number {
|
||||||
|
const a = Date.UTC(+checkIn.slice(0, 4), +checkIn.slice(5, 7) - 1, +checkIn.slice(8, 10))
|
||||||
|
const b = Date.UTC(+checkOut.slice(0, 4), +checkOut.slice(5, 7) - 1, +checkOut.slice(8, 10))
|
||||||
|
return Math.round((b - a) / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand half-open [start, end) spans into the sorted, de-duplicated list of
|
||||||
|
* occupied nights. A year of a busy room is a few hundred strings — cheap to
|
||||||
|
* hold and to binary-search.
|
||||||
|
*/
|
||||||
|
export function expandRanges(ranges: DateRange[]): string[] {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const r of ranges) {
|
||||||
|
for (let d = r.start; d < r.end; d = addDays(d, 1)) out.add(d)
|
||||||
|
}
|
||||||
|
return [...out].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First occupied night ≥ `ymd`, or undefined if none in the loaded window. */
|
||||||
|
export function nextUnavailableOnOrAfter(nights: string[], ymd: string): string | undefined {
|
||||||
|
let lo = 0
|
||||||
|
let hi = nights.length
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1
|
||||||
|
if (nights[mid] < ymd) lo = mid + 1
|
||||||
|
else hi = mid
|
||||||
|
}
|
||||||
|
return nights[lo]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when every night of [checkIn, checkOut) is free. */
|
||||||
|
export function stayIsFree(nightSet: Set<string>, checkIn: string, checkOut: string): boolean {
|
||||||
|
if (!(checkIn < checkOut)) return false
|
||||||
|
for (let d = checkIn; d < checkOut; d = addDays(d, 1)) {
|
||||||
|
if (nightSet.has(d)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
@ -1,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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
121
src/modules/chatelet/views/MyBookingsPage.vue
Normal file
121
src/modules/chatelet/views/MyBookingsPage.vue
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue