feat(chatelet): stay calendar, room gallery + house rules, My bookings #156

Merged
padreug merged 4 commits from feat/chatelet-my-bookings into dev 2026-09-21 09:12:07 +00:00
7 changed files with 259 additions and 8 deletions
Showing only changes of commit 3c6954f144 - Show all commits

feat(chatelet): My bookings page and bottom navigation

/my-bookings (auth-required, mirrors events' /my-tickets) lists the
signed-in guest's stays from the extension's new GET /bookings/mine
(chatelet ≥ v0.5.0): upcoming and past, room title and photo joined from
the public room list, dates and nights, guest count, amount, and a status
badge — unpaid holds show until when the hold lasts. The standalone app
gains its first bottom tabs, Rooms and My bookings, the latter ghosted
with a log-in toast when signed out; the booking confirmation links there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Padreug 2026-09-16 12:19:56 +02:00

View file

@ -1,13 +1,40 @@
<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'
// 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[] = []
const route = useRoute()
const router = useRouter()
const { isAuthenticated } = useAuth()
function isActive(_path: string): boolean {
return false
// 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 + '/')
}
</script>

View file

@ -1,5 +1,6 @@
<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'
@ -47,6 +48,12 @@ 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 --------------------------------------------------
@ -346,7 +353,10 @@ watch(
<p class="text-xs text-muted-foreground">
Check-in details arrive as a private Nostr message from the host.
</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>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,66 @@
import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import { getLocalTimeZone, today } from '@internationalized/date'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { ChateletApiService } from '../services/ChateletApiService'
import { LOST_BOOKING_STATUSES, type GuestBooking } from '../types/booking'
import { useChateletStore } from '../stores/chatelet'
/**
* The signed-in guest's stays, split into upcoming and past. Room titles are
* joined client-side from the public room list (a handful of rooms per
* install); a room that has since gone inactive falls back to its id.
*/
export function useMyBookings() {
const { isAuthenticated, currentUser } = useAuth()
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
const store = useChateletStore()
const {
state: bookings,
isLoading,
error: asyncError,
execute: refresh,
} = useAsyncState(
async () => {
if (!api || !isAuthenticated.value || !currentUser.value) return []
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken: string = lnbitsAPI?.getAccessToken?.() || ''
return await api.getMyBookings(accessToken)
},
[] as GuestBooking[],
{ immediate: false, resetOnExecute: false },
)
const error = computed(() =>
asyncError.value
? asyncError.value instanceof Error
? asyncError.value.message
: 'Could not load your bookings'
: null,
)
const todayYmd = today(getLocalTimeZone()).toString()
/** Still relevant: not over yet and the hold/booking is alive. */
const upcoming = computed(() =>
bookings.value
.filter((b) => b.check_out > todayYmd && !LOST_BOOKING_STATUSES.has(b.status))
.sort((a, b) => a.check_in.localeCompare(b.check_in)),
)
const past = computed(() =>
bookings.value
.filter((b) => b.check_out <= todayYmd || LOST_BOOKING_STATUSES.has(b.status))
.sort((a, b) => b.check_in.localeCompare(a.check_in)),
)
function roomTitle(roomId: string): string {
return store.rooms.find((r) => r.id === roomId)?.title ?? `Room ${roomId}`
}
function roomImage(roomId: string): string | undefined {
return store.rooms.find((r) => r.id === roomId)?.images?.[0]
}
return { bookings, upcoming, past, isLoading, error, refresh, roomTitle, roomImage }
}

View file

@ -31,6 +31,14 @@ export const chateletModule = createModulePlugin({
component: () => import('./views/ChateletDetailPage.vue'),
meta: { title: 'Room', requiresAuth: false },
},
{
// Mirrors events' /my-tickets; kept off /chatelet/* so it never
// collides with the :id room route.
path: '/my-bookings',
name: 'chatelet-my-bookings',
component: () => import('./views/MyBookingsPage.vue'),
meta: { title: 'My bookings', requiresAuth: true },
},
],
onInstall: async (_app, options) => {
@ -52,4 +60,4 @@ export const chateletModule = createModulePlugin({
export default chateletModule
export type { Room, AvailabilityResult } from './types/room'
export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking'
export type { Booking, BookingQuote, BookingRequest, BookingStatus, GuestBooking } from './types/booking'

View file

@ -1,5 +1,5 @@
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 {
baseUrl: string
@ -84,6 +84,18 @@ export class ChateletApiService {
return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' })
}
/**
* The signed-in guest's own stays, newest check-in first. LNbits account
* auth (Bearer), the same way events fetches a user's tickets; the backend
* matches on the account's Nostr pubkey.
*/
async getMyBookings(accessToken: string): Promise<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',

View file

@ -70,6 +70,13 @@ export type PublicBooking = Omit<
'guest_pubkey' | 'guest_contact' | 'payment_hash'
>
/**
* A booking as returned to its own guest by `GET /chatelet/api/v1/bookings/mine`
* (LNbits account auth, chatelet ≥ v0.5.0): the guest keeps their contact and
* counts; the Lightning/Nostr plumbing stays server-side.
*/
export type GuestBooking = Omit<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',

View file

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