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

View File

@@ -0,0 +1,644 @@
<script lang="ts" setup>
import type { PublicBookingReceipt, PublicBookingSeatWithUrl } from '~~/shared/booking'
import {
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getBookingModeLabel,
getBookingStatusLabel,
getSeatLabel,
getTicketCatalogItem
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
type ReceiptTabId = 'main' | 'status' | 'seats'
const route = useRoute()
const toast = useToast()
const apiClient = useApiClient()
const token = String(route.params.token || '')
const activeTab = ref<ReceiptTabId>('main')
const receiptActionLoading = ref(false)
const shareSeatsLoading = ref(false)
const seatActionId = ref<string | null>(null)
const expandedSeatIds = ref<string[]>([])
const shareForm = reactive({
count: 1,
recipientName: '',
recipientPhone: ''
})
const tabs = [
{ id: 'main' as const, label: 'Main QR', icon: 'i-lucide-qr-code' },
{ id: 'status' as const, label: 'Status', icon: 'i-lucide-badge-check' },
{ id: 'seats' as const, label: 'Seat List', icon: 'i-lucide-users' }
]
let initialReceipt: PublicBookingReceipt
try {
initialReceipt = await apiClient<PublicBookingReceipt>(`/api/public/receipts/${token}`)
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.data?.statusCode || 404,
statusMessage: error?.data?.statusMessage || error?.message || 'Receipt 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))
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
const maxShareCount = computed(() => Math.max(availableSeats.value.length, 1))
const normalizedShareCount = computed(() => {
return Math.max(1, Math.min(Math.trunc(Number(shareForm.count) || 1), maxShareCount.value))
})
const seatsToShare = computed(() => availableSeats.value.slice(0, normalizedShareCount.value))
const seatsToShareLabel = computed(() => seatsToShare.value.map((seat) => getSeatLabel(seat.seatNumber)).join(', '))
const detailRows = computed(() => {
const rows = [
{ label: 'Guest', value: receipt.value.booking.customerName },
{ label: 'Phone', value: receipt.value.booking.customerPhone },
{ label: 'Booking', value: getBookingModeLabel(receipt.value.booking.bookingMode) },
{ label: 'Category', value: ticketLabel.value },
{ label: 'Quantity', value: String(receipt.value.booking.quantity) },
{ label: 'Seats', value: String(receipt.value.booking.seatCount) },
{ label: 'Submitted', value: formatDateTime(receipt.value.booking.createdAt) }
]
if (receipt.value.booking.confirmedAt) {
rows.push({
label: 'Confirmed',
value: formatDateTime(receipt.value.booking.confirmedAt)
})
}
return rows
})
watch(
availableSeats,
(nextSeats) => {
shareForm.count = Math.max(1, Math.min(shareForm.count, Math.max(nextSeats.length, 1)))
},
{ immediate: true }
)
function updateSeat(nextSeat: PublicBookingSeatWithUrl) {
receipt.value = {
...receipt.value,
seats: receipt.value.seats.map((seat) => seat.id === nextSeat.id ? nextSeat : seat)
}
}
function isSeatExpanded(seatId: string) {
return expandedSeatIds.value.includes(seatId)
}
function toggleSeatExpanded(seatId: string) {
expandedSeatIds.value = isSeatExpanded(seatId)
? expandedSeatIds.value.filter((id) => id !== seatId)
: [...expandedSeatIds.value, seatId]
}
function buildSeatBundleText(seats: PublicBookingSeatWithUrl[]) {
const recipientLabel = shareForm.recipientName.trim()
? `Recipient: ${shareForm.recipientName.trim()}`
: null
return [
DINNER_EVENT_TITLE,
`Guest: ${receipt.value.booking.customerName}`,
recipientLabel,
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
`Category: ${ticketLabel.value}`,
`Date: ${DINNER_EVENT_DATE_LABEL}`,
`Time: ${DINNER_EVENT_TIME_LABEL}`,
`Venue: ${DINNER_EVENT_VENUE}`,
'',
...seats.flatMap((seat) => [
`${getSeatLabel(seat.seatNumber)}:`,
seat.seatUrl,
''
])
].filter(Boolean).join('\n')
}
async function shareLink(options: {
title: string
text: string
url?: string
clipboardText?: string
successTitle: string
successDescription: string
}) {
if (!import.meta.client) {
return false
}
const clipboardText = options.clipboardText || options.url || options.text
try {
if (navigator.share) {
await navigator.share({
title: options.title,
text: options.text,
url: options.url
})
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(clipboardText)
toast.add({
title: options.successTitle,
description: `${options.successDescription} Copied to clipboard.`,
color: 'success',
icon: 'i-lucide-copy-check'
})
} else {
window.prompt('Copy this text', clipboardText)
}
return true
} catch (error: any) {
if (error?.name === 'AbortError') {
return false
}
throw error
}
}
async function patchSeatShare(
seat: PublicBookingSeatWithUrl,
body: {
shared: boolean
recipientName?: string
recipientPhone?: string
}
) {
const response = await apiClient<{ seat: PublicBookingSeatWithUrl }>(
`/api/public/receipts/${token}/seats/${seat.id}`,
{
method: 'PATCH',
body
}
)
updateSeat(response.seat)
return response.seat
}
async function shareReceiptLink() {
if (receiptActionLoading.value) {
return
}
receiptActionLoading.value = true
try {
const shared = await shareLink({
title: `${DINNER_EVENT_TITLE} receipt`,
text: `Ticket receipt for ${receipt.value.booking.customerName}.`,
url: receipt.value.receiptUrl,
successTitle: 'Receipt ready',
successDescription: 'Main receipt link prepared.'
})
if (shared && import.meta.client && navigator.share) {
toast.add({
title: 'Receipt shared',
color: 'success',
icon: 'i-lucide-share-2'
})
}
} catch (error) {
toast.add({
title: 'Unable to share receipt',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
receiptActionLoading.value = false
}
}
async function shareSeats() {
if (!availableSeats.value.length || shareSeatsLoading.value || seatActionId.value) {
return
}
shareSeatsLoading.value = true
try {
const seats = seatsToShare.value
const shared = await shareLink({
title: `${DINNER_EVENT_TITLE} seats`,
text: buildSeatBundleText(seats),
clipboardText: buildSeatBundleText(seats),
successTitle: 'Seats ready',
successDescription: `${seats.length} seat link${seats.length > 1 ? 's are' : ' is'} ready to send.`
})
if (!shared) {
return
}
let successCount = 0
for (const seat of seats) {
try {
await patchSeatShare(seat, {
shared: true,
recipientName: shareForm.recipientName,
recipientPhone: shareForm.recipientPhone
})
successCount += 1
} catch {
continue
}
}
if (!successCount) {
toast.add({
title: 'Seat update failed',
description: 'The share sheet opened, but the seat records could not be updated.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
return
}
toast.add({
title: `${successCount} seat${successCount > 1 ? 's' : ''} shared`,
description: successCount === seats.length
? 'Next available seats were sent.'
: 'Some seats were sent, but at least one update failed.',
color: successCount === seats.length ? 'success' : 'warning',
icon: successCount === seats.length ? 'i-lucide-check-check' : 'i-lucide-triangle-alert'
})
} catch (error) {
toast.add({
title: 'Unable to share seats',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
shareSeatsLoading.value = false
}
}
async function unshareSeat(seat: PublicBookingSeatWithUrl) {
if (seatActionId.value || shareSeatsLoading.value) {
return
}
if (import.meta.client && !window.confirm(`Unshare ${getSeatLabel(seat.seatNumber)}? The previous link will stop working.`)) {
return
}
seatActionId.value = seat.id
try {
await patchSeatShare(seat, { shared: false })
toast.add({
title: `${getSeatLabel(seat.seatNumber)} unshared`,
color: 'success',
icon: 'i-lucide-rotate-ccw'
})
} catch (error) {
toast.add({
title: 'Unable to unshare seat',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
seatActionId.value = null
}
}
</script>
<template>
<UContainer class="py-6 sm:py-8">
<div class="mx-auto max-w-5xl space-y-5">
<div class="space-y-1 text-center">
<UBadge label="Ticket Receipt" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
{{ DINNER_EVENT_TITLE }}
</h1>
</div>
<div class="overflow-x-auto rounded-2xl border border-default bg-default p-2 shadow-sm">
<div class="flex min-w-max gap-2 sm:min-w-0 sm:grid sm:grid-cols-3">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="flex min-h-11 min-w-32 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition sm:min-w-0"
:class="activeTab === tab.id
? 'bg-primary text-inverted shadow-sm'
: 'bg-elevated text-default hover:bg-muted'"
@click="activeTab = tab.id"
>
<UIcon :name="tab.icon" class="size-4" />
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<UCard
v-if="activeTab === 'main'"
class="border border-default bg-default shadow-sm"
:ui="{ body: 'space-y-5 p-4 sm:p-6' }"
>
<div class="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
<div class="space-y-4 rounded-2xl border border-default bg-elevated p-4 text-center">
<QrCodeSvg :value="receipt.receiptUrl" :size="220" />
<div class="flex flex-col gap-2">
<UButton
label="Share Receipt"
icon="i-lucide-share-2"
class="justify-center"
:loading="receiptActionLoading"
@click="shareReceiptLink"
/>
<UButton
:to="receipt.receiptUrl"
target="_blank"
label="Open Link"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
class="justify-center"
/>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Status
</p>
<div class="mt-2">
<UBadge :label="getBookingStatusLabel(receipt.booking.status)" :color="statusColor" variant="soft" />
</div>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Shared
</p>
<p class="mt-2 text-2xl font-bold text-highlighted">
{{ sharedSeats.length }}
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Available
</p>
<p class="mt-2 text-2xl font-bold text-highlighted">
{{ availableSeats.length }}
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Total
</p>
<p class="mt-2 text-2xl font-bold text-highlighted">
{{ totalFormatted }}
</p>
</div>
<div class="overflow-hidden rounded-2xl border border-default sm:col-span-2 xl:col-span-4">
<div
v-for="row in detailRows"
:key="row.label"
class="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-3 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[9rem_minmax(0,1fr)]"
>
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
{{ row.label }}
</div>
<div class="min-w-0 font-medium text-highlighted break-words">
{{ row.value }}
</div>
</div>
</div>
</div>
</div>
</UCard>
<UCard
v-else-if="activeTab === 'status'"
class="border border-default bg-default shadow-sm"
:ui="{ body: 'space-y-4 p-4 sm:p-6' }"
>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Status
</p>
<div class="mt-2">
<UBadge :label="getBookingStatusLabel(receipt.booking.status)" :color="statusColor" variant="soft" />
</div>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Date
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ DINNER_EVENT_DATE_LABEL }}
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Time
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ DINNER_EVENT_TIME_LABEL }}
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Venue
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ DINNER_EVENT_VENUE }}
</p>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-default">
<div
v-for="row in detailRows"
:key="row.label"
class="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-3 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[9rem_minmax(0,1fr)]"
>
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
{{ row.label }}
</div>
<div class="min-w-0 font-medium text-highlighted break-words">
{{ row.value }}
</div>
</div>
</div>
</UCard>
<UCard
v-else
class="border border-default bg-default shadow-sm"
:ui="{ body: 'space-y-5 p-4 sm:p-6' }"
>
<div class="grid gap-4 xl:grid-cols-[20rem_minmax(0,1fr)]">
<div class="space-y-4 rounded-2xl border border-default bg-elevated p-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div class="rounded-2xl border border-default bg-default p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Seats To Share
</p>
<UInputNumber
v-model="shareForm.count"
:min="1"
:max="maxShareCount"
:disabled="!availableSeats.length || shareSeatsLoading"
size="xl"
class="mt-3 w-full"
/>
</div>
<div class="rounded-2xl border border-default bg-default p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Next Seats
</p>
<p class="mt-2 text-sm font-medium text-highlighted break-words">
{{ availableSeats.length ? seatsToShareLabel : 'No seats available' }}
</p>
</div>
</div>
<UFormField label="Recipient Name">
<UInput
v-model="shareForm.recipientName"
class="w-full"
placeholder="Optional"
/>
</UFormField>
<UFormField label="Recipient Phone">
<UInput
v-model="shareForm.recipientPhone"
type="tel"
class="w-full"
placeholder="Optional"
/>
</UFormField>
<UButton
label="Share Seats"
icon="i-lucide-share-2"
class="w-full justify-center"
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
:loading="shareSeatsLoading"
@click="shareSeats"
/>
</div>
<div class="space-y-3">
<div
v-for="seat in receipt.seats"
:key="seat.id"
class="overflow-hidden rounded-2xl border border-default bg-elevated"
>
<button
type="button"
class="flex min-h-11 w-full items-start justify-between gap-3 px-4 py-4 text-left"
@click="toggleSeatExpanded(seat.id)"
>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<p class="text-base font-semibold text-highlighted">
{{ getSeatLabel(seat.seatNumber) }}
</p>
<UBadge
:label="seat.sharedAt ? 'Shared' : 'Available'"
:color="seat.sharedAt ? 'primary' : 'neutral'"
variant="soft"
size="sm"
/>
</div>
<p class="text-sm text-muted">
{{ seat.recipientName || 'Unassigned' }}
<span v-if="seat.recipientPhone"> · {{ seat.recipientPhone }}</span>
</p>
</div>
<UIcon
name="i-lucide-chevron-down"
class="mt-1 size-4 shrink-0 text-muted transition-transform"
:class="isSeatExpanded(seat.id) ? 'rotate-180' : ''"
/>
</button>
<div
v-if="isSeatExpanded(seat.id)"
class="border-t border-default bg-default px-4 py-4"
>
<div class="grid gap-4 lg:grid-cols-[12rem_minmax(0,1fr)]">
<div class="flex justify-center">
<QrCodeSvg :value="seat.seatUrl" :size="170" />
</div>
<div class="space-y-3">
<div class="rounded-2xl border border-default bg-elevated p-4 text-sm text-default">
<p class="font-medium text-highlighted">
{{ seat.recipientName || 'Unassigned' }}
</p>
<p v-if="seat.recipientPhone" class="mt-1 text-muted">
{{ seat.recipientPhone }}
</p>
<p class="mt-2 text-muted">
{{ seat.sharedAt ? `Shared ${formatDateTime(seat.sharedAt)}` : 'Available to share' }}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<UButton
:to="seat.seatUrl"
target="_blank"
label="Open Seat"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
class="flex-1 justify-center"
/>
<UButton
label="Unshare"
color="neutral"
variant="outline"
icon="i-lucide-user-minus"
class="flex-1 justify-center"
:disabled="!seat.sharedAt || seatActionId === seat.id || shareSeatsLoading"
@click="unshareSeat(seat)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</UCard>
</div>
</UContainer>
</template>