feat(bookings): implement ticket receipts and seat sharing system

Add receipt tokens and booking_seats table to track individual tickets
Create receipt and seat view pages with QR code generation
This commit is contained in:
2026-04-12 22:48:26 +08:00
parent 7f582b530c
commit 6194c96ead
15 changed files with 1663 additions and 61 deletions

173
app/pages/seat/[token].vue Normal file
View File

@@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { PublicSeatReceipt } from '~~/shared/booking'
import {
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getBookingModeLabel,
getSeatLabel,
getTicketCatalogItem
} from '~~/shared/booking'
import { formatDateTime } from '../../utils/formatters'
const route = useRoute()
const apiClient = useApiClient()
const token = String(route.params.token || '')
let initialReceipt: PublicSeatReceipt
try {
initialReceipt = await apiClient<PublicSeatReceipt>(`/api/public/seats/${token}`)
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.data?.statusCode || 404,
statusMessage: error?.data?.statusMessage || error?.message || 'Seat ticket not found'
})
}
const receipt = ref(initialReceipt)
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(receipt.value.booking.totalPrice))
</script>
<template>
<UContainer class="py-8">
<div class="mx-auto max-w-4xl space-y-6">
<div class="space-y-2 text-center">
<UBadge label="Seat Ticket" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
{{ getSeatLabel(receipt.seat.seatNumber) }}
</h1>
<p class="text-sm text-muted">
{{ DINNER_EVENT_TITLE }}
</p>
</div>
<div class="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'space-y-4 p-4 sm:p-5' }">
<div class="space-y-1 text-center">
<p class="text-sm font-semibold text-highlighted">
QR Code
</p>
<p class="text-xs text-muted">
Present this QR code at check-in.
</p>
</div>
<div class="flex justify-center">
<QrCodeSvg :value="receipt.seat.seatUrl" :size="220" />
</div>
<div class="rounded-2xl border border-default bg-elevated p-4 text-sm text-default">
<p class="font-medium text-highlighted">
{{ receipt.seat.recipientName || receipt.booking.customerName }}
</p>
<p v-if="receipt.seat.recipientPhone" class="mt-1 text-muted">
{{ receipt.seat.recipientPhone }}
</p>
<p class="mt-3 text-muted">
{{ receipt.booking.status === 'confirmed' ? 'Booking confirmed' : 'Booking pending confirmation' }}
</p>
</div>
</UCard>
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'space-y-5 p-4 sm:p-5' }">
<div class="space-y-1">
<h2 class="text-xl font-semibold text-highlighted">
Booking Details
</h2>
<p class="text-sm text-muted">
Linked back to the main booking receipt.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Guest / Organizer
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ receipt.booking.customerName }}
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Ticket Category
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ ticketLabel }}
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Booking Mode
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ getBookingModeLabel(receipt.booking.bookingMode) }}
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Total Booking Value
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ totalFormatted }}
</p>
</div>
</div>
<div class="space-y-3 rounded-2xl border border-default bg-elevated p-4">
<div class="flex items-center gap-3 text-sm text-default">
<UIcon name="i-lucide-calendar-days" class="size-4 text-muted" />
<span>{{ DINNER_EVENT_DATE_LABEL }}</span>
</div>
<div class="flex items-center gap-3 text-sm text-default">
<UIcon name="i-lucide-clock-6" class="size-4 text-muted" />
<span>{{ DINNER_EVENT_TIME_LABEL }}</span>
</div>
<div class="flex items-center gap-3 text-sm text-default">
<UIcon name="i-lucide-map-pin" class="size-4 text-muted" />
<span>{{ DINNER_EVENT_VENUE }}</span>
</div>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4 text-sm text-default">
<p class="font-medium text-highlighted">
Seat shared {{ receipt.seat.sharedAt ? formatDateTime(receipt.seat.sharedAt) : 'recently' }}
</p>
<p class="mt-1 text-muted">
Submitted {{ formatDateTime(receipt.booking.createdAt) }}
</p>
<p v-if="receipt.booking.confirmedAt" class="mt-1 text-muted">
Confirmed {{ formatDateTime(receipt.booking.confirmedAt) }}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<UButton
:to="receipt.receiptUrl"
label="Open Main Receipt"
icon="i-lucide-receipt"
class="flex-1 justify-center"
/>
<UButton
:to="receipt.seat.seatUrl"
target="_blank"
label="Open Ticket Link"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
class="flex-1 justify-center"
/>
</div>
</UCard>
</div>
</div>
</UContainer>
</template>