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:
69
app/components/QrCodeSvg.vue
Normal file
69
app/components/QrCodeSvg.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: string
|
||||
size?: number
|
||||
}>(), {
|
||||
size: 220
|
||||
})
|
||||
|
||||
const markup = ref('')
|
||||
const renderError = ref('')
|
||||
|
||||
async function renderQrCode() {
|
||||
if (!props.value) {
|
||||
markup.value = ''
|
||||
renderError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
markup.value = await QRCode.toString(props.value, {
|
||||
type: 'svg',
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: props.size
|
||||
})
|
||||
renderError.value = ''
|
||||
} catch (error: any) {
|
||||
markup.value = ''
|
||||
renderError.value = error?.message || 'Unable to generate QR code.'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.value, props.size],
|
||||
() => {
|
||||
void renderQrCode()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex flex-col items-center gap-2">
|
||||
<div
|
||||
v-if="markup"
|
||||
class="overflow-hidden rounded-2xl border border-default bg-default p-4 shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="[&>svg]:block [&>svg]:h-auto [&>svg]:w-full"
|
||||
:style="{ width: `${size}px` }"
|
||||
v-html="markup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center rounded-2xl border border-dashed border-muted bg-elevated p-4 text-center text-sm text-muted"
|
||||
:style="{ width: `${size}px`, minHeight: `${size}px` }"
|
||||
>
|
||||
QR code unavailable
|
||||
</div>
|
||||
|
||||
<p v-if="renderError" class="text-center text-xs text-error">
|
||||
{{ renderError }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -276,6 +276,14 @@
|
||||
icon="i-lucide-external-link"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
:to="receiptPath(row.original)"
|
||||
label="Receipt"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-receipt"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
@@ -408,6 +416,10 @@ function confirmationPath(booking: PublicBooking) {
|
||||
return `/confirmation/${booking.confirmationToken}`
|
||||
}
|
||||
|
||||
function receiptPath(booking: PublicBooking) {
|
||||
return `/receipt/${booking.receiptToken}`
|
||||
}
|
||||
|
||||
function formatInventoryNumber(value: number | null) {
|
||||
return value === null ? 'Not set' : String(value)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const booking = ref(initialBooking)
|
||||
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
|
||||
const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase())
|
||||
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
|
||||
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
|
||||
const detailRows = computed(() => {
|
||||
const rows = [
|
||||
{
|
||||
@@ -194,6 +195,16 @@ async function confirmBooking() {
|
||||
class="justify-center"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-if="booking.status === 'confirmed'"
|
||||
:to="receiptPath"
|
||||
label="Open Ticket Receipt"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-receipt"
|
||||
class="justify-center"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
label="Confirm This Booking"
|
||||
icon="i-lucide-check-check"
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { CreateBookingResponse } from '~~/shared/booking'
|
||||
import {
|
||||
BOOKING_MODE_OPTIONS,
|
||||
BOOKING_TICKET_CATALOG,
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getSeatCount,
|
||||
getTicketCatalogItem,
|
||||
@@ -22,17 +26,17 @@ const apiClient = useApiClient()
|
||||
const eventDetails = [
|
||||
{
|
||||
label: 'Date',
|
||||
value: 'Saturday, 30 May 2026',
|
||||
value: DINNER_EVENT_DATE_LABEL,
|
||||
icon: 'lucide:calendar-days'
|
||||
},
|
||||
{
|
||||
label: 'Time',
|
||||
value: '6:30 PM',
|
||||
value: DINNER_EVENT_TIME_LABEL,
|
||||
icon: 'lucide:clock-6'
|
||||
},
|
||||
{
|
||||
label: 'Venue',
|
||||
value: "Yong Peng's Chee Ann Kor",
|
||||
value: DINNER_EVENT_VENUE,
|
||||
icon: 'lucide:map-pin'
|
||||
}
|
||||
] as const
|
||||
@@ -151,7 +155,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-4xl">
|
||||
DAP JOHOR 60th Anniversary Celebration
|
||||
{{ DINNER_EVENT_TITLE }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
644
app/pages/receipt/[token].vue
Normal file
644
app/pages/receipt/[token].vue
Normal 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>
|
||||
173
app/pages/seat/[token].vue
Normal file
173
app/pages/seat/[token].vue
Normal 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>
|
||||
Reference in New Issue
Block a user