feat(chatelet): stay calendar, room gallery + house rules, My bookings #156
7 changed files with 259 additions and 8 deletions
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>
commit
3c6954f144
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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 }
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
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