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>
This commit is contained in:
parent
fb0478c028
commit
3c6954f144
7 changed files with 259 additions and 8 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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'),
|
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'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { AvailabilityResult, Room, UnavailableRanges } 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
|
||||||
|
|
@ -84,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',
|
||||||
|
|
|
||||||
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