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"
|
icon="i-lucide-external-link"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<UButton
|
||||||
|
:to="receiptPath(row.original)"
|
||||||
|
label="Receipt"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-receipt"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
@@ -408,6 +416,10 @@ function confirmationPath(booking: PublicBooking) {
|
|||||||
return `/confirmation/${booking.confirmationToken}`
|
return `/confirmation/${booking.confirmationToken}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function receiptPath(booking: PublicBooking) {
|
||||||
|
return `/receipt/${booking.receiptToken}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatInventoryNumber(value: number | null) {
|
function formatInventoryNumber(value: number | null) {
|
||||||
return value === null ? 'Not set' : String(value)
|
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 statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
|
||||||
const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase())
|
const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase())
|
||||||
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
|
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
|
||||||
|
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
|
||||||
const detailRows = computed(() => {
|
const detailRows = computed(() => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -194,6 +195,16 @@ async function confirmBooking() {
|
|||||||
class="justify-center"
|
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
|
<UButton
|
||||||
label="Confirm This Booking"
|
label="Confirm This Booking"
|
||||||
icon="i-lucide-check-check"
|
icon="i-lucide-check-check"
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type { CreateBookingResponse } from '~~/shared/booking'
|
|||||||
import {
|
import {
|
||||||
BOOKING_MODE_OPTIONS,
|
BOOKING_MODE_OPTIONS,
|
||||||
BOOKING_TICKET_CATALOG,
|
BOOKING_TICKET_CATALOG,
|
||||||
|
DINNER_EVENT_DATE_LABEL,
|
||||||
|
DINNER_EVENT_TIME_LABEL,
|
||||||
|
DINNER_EVENT_TITLE,
|
||||||
|
DINNER_EVENT_VENUE,
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getSeatCount,
|
getSeatCount,
|
||||||
getTicketCatalogItem,
|
getTicketCatalogItem,
|
||||||
@@ -22,17 +26,17 @@ const apiClient = useApiClient()
|
|||||||
const eventDetails = [
|
const eventDetails = [
|
||||||
{
|
{
|
||||||
label: 'Date',
|
label: 'Date',
|
||||||
value: 'Saturday, 30 May 2026',
|
value: DINNER_EVENT_DATE_LABEL,
|
||||||
icon: 'lucide:calendar-days'
|
icon: 'lucide:calendar-days'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Time',
|
label: 'Time',
|
||||||
value: '6:30 PM',
|
value: DINNER_EVENT_TIME_LABEL,
|
||||||
icon: 'lucide:clock-6'
|
icon: 'lucide:clock-6'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Venue',
|
label: 'Venue',
|
||||||
value: "Yong Peng's Chee Ann Kor",
|
value: DINNER_EVENT_VENUE,
|
||||||
icon: 'lucide:map-pin'
|
icon: 'lucide:map-pin'
|
||||||
}
|
}
|
||||||
] as const
|
] as const
|
||||||
@@ -151,7 +155,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-4xl">
|
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-4xl">
|
||||||
DAP JOHOR 60th Anniversary Celebration
|
{{ DINNER_EVENT_TITLE }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@simplewebauthn/server": "^13.3.0",
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^5.11.0",
|
"redis": "^5.11.0",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4"
|
||||||
|
|||||||
163
pnpm-lock.yaml
generated
163
pnpm-lock.yaml
generated
@@ -10,7 +10,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/ui':
|
'@nuxt/ui':
|
||||||
specifier: 4.6.1
|
specifier: 4.6.1
|
||||||
version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)
|
version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(qrcode@1.5.4)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)
|
||||||
'@simplewebauthn/browser':
|
'@simplewebauthn/browser':
|
||||||
specifier: ^13.3.0
|
specifier: ^13.3.0
|
||||||
version: 13.3.0
|
version: 13.3.0
|
||||||
@@ -23,6 +23,9 @@ importers:
|
|||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.9
|
specifier: ^3.4.9
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
redis:
|
redis:
|
||||||
specifier: ^5.11.0
|
specifier: ^5.11.0
|
||||||
version: 5.11.0
|
version: 5.11.0
|
||||||
@@ -2076,6 +2079,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
caniuse-api@3.0.0:
|
caniuse-api@3.0.0:
|
||||||
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
|
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
|
||||||
|
|
||||||
@@ -2100,6 +2107,9 @@ packages:
|
|||||||
citty@0.2.2:
|
citty@0.2.2:
|
||||||
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
|
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@9.0.1:
|
cliui@9.0.1:
|
||||||
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -2274,6 +2284,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
deepmerge@4.3.1:
|
deepmerge@4.3.1:
|
||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2315,6 +2329,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
@@ -2517,6 +2534,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
fontaine@0.8.0:
|
fontaine@0.8.0:
|
||||||
resolution: {integrity: sha512-eek1GbzOdWIj9FyQH/emqW1aEdfC3lYRCHepzwlFCm5T77fBSRSyNRKE6/antF1/B1M+SfJXVRQTY9GAr7lnDg==}
|
resolution: {integrity: sha512-eek1GbzOdWIj9FyQH/emqW1aEdfC3lYRCHepzwlFCm5T77fBSRSyNRKE6/antF1/B1M+SfJXVRQTY9GAr7lnDg==}
|
||||||
engines: {node: '>=18.12.0'}
|
engines: {node: '>=18.12.0'}
|
||||||
@@ -2894,6 +2915,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
@@ -3163,6 +3188,18 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
oxc-parser: '>=0.98.0'
|
oxc-parser: '>=0.98.0'
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
p-try@2.2.0:
|
||||||
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
@@ -3173,6 +3210,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
path-exists@4.0.0:
|
||||||
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
path-key@3.1.1:
|
path-key@3.1.1:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3218,6 +3259,10 @@ packages:
|
|||||||
pkg-types@2.3.0:
|
pkg-types@2.3.0:
|
||||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
postcss-calc@10.1.1:
|
postcss-calc@10.1.1:
|
||||||
resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==}
|
resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==}
|
||||||
engines: {node: ^18.12 || ^20.9 || >=22.0}
|
engines: {node: ^18.12 || ^20.9 || >=22.0}
|
||||||
@@ -3479,6 +3524,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
quansync@0.2.11:
|
quansync@0.2.11:
|
||||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
@@ -3537,6 +3587,13 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: '>= 3.4.0'
|
vue: '>= 3.4.0'
|
||||||
|
|
||||||
|
require-directory@2.1.1:
|
||||||
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve-from@5.0.0:
|
resolve-from@5.0.0:
|
||||||
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3622,6 +3679,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
@@ -4185,6 +4245,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
|
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -4195,6 +4258,10 @@ packages:
|
|||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4233,6 +4300,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
yjs: ^13.0.0
|
yjs: ^13.0.0
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4249,10 +4319,18 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@22.0.0:
|
yargs-parser@22.0.0:
|
||||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@18.0.0:
|
yargs@18.0.0:
|
||||||
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||||
@@ -5006,7 +5084,7 @@ snapshots:
|
|||||||
rc9: 3.0.1
|
rc9: 3.0.1
|
||||||
std-env: 4.0.0
|
std-env: 4.0.0
|
||||||
|
|
||||||
'@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)':
|
'@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(qrcode@1.5.4)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
'@iconify/vue': 5.0.0(vue@3.5.32(typescript@6.0.2))
|
'@iconify/vue': 5.0.0(vue@3.5.32(typescript@6.0.2))
|
||||||
@@ -5041,7 +5119,7 @@ snapshots:
|
|||||||
'@tiptap/vue-3': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(vue@3.5.32(typescript@6.0.2))
|
'@tiptap/vue-3': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(vue@3.5.32(typescript@6.0.2))
|
||||||
'@unhead/vue': 2.1.13(vue@3.5.32(typescript@6.0.2))
|
'@unhead/vue': 2.1.13(vue@3.5.32(typescript@6.0.2))
|
||||||
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
||||||
'@vueuse/integrations': 14.2.1(fuse.js@7.3.0)(vue@3.5.32(typescript@6.0.2))
|
'@vueuse/integrations': 14.2.1(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.32(typescript@6.0.2))
|
||||||
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
||||||
colortranslator: 5.0.0
|
colortranslator: 5.0.0
|
||||||
consola: 3.4.2
|
consola: 3.4.2
|
||||||
@@ -6271,13 +6349,14 @@ snapshots:
|
|||||||
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
||||||
vue: 3.5.32(typescript@6.0.2)
|
vue: 3.5.32(typescript@6.0.2)
|
||||||
|
|
||||||
'@vueuse/integrations@14.2.1(fuse.js@7.3.0)(vue@3.5.32(typescript@6.0.2))':
|
'@vueuse/integrations@14.2.1(fuse.js@7.3.0)(qrcode@1.5.4)(vue@3.5.32(typescript@6.0.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
||||||
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@6.0.2))
|
||||||
vue: 3.5.32(typescript@6.0.2)
|
vue: 3.5.32(typescript@6.0.2)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fuse.js: 7.3.0
|
fuse.js: 7.3.0
|
||||||
|
qrcode: 1.5.4
|
||||||
|
|
||||||
'@vueuse/metadata@10.11.1': {}
|
'@vueuse/metadata@10.11.1': {}
|
||||||
|
|
||||||
@@ -6488,6 +6567,8 @@ snapshots:
|
|||||||
|
|
||||||
cac@6.7.14: {}
|
cac@6.7.14: {}
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
caniuse-api@3.0.0:
|
caniuse-api@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.2
|
browserslist: 4.28.2
|
||||||
@@ -6513,6 +6594,12 @@ snapshots:
|
|||||||
|
|
||||||
citty@0.2.2: {}
|
citty@0.2.2: {}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
cliui@9.0.1:
|
cliui@9.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 7.2.0
|
string-width: 7.2.0
|
||||||
@@ -6668,6 +6755,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
default-browser-id@5.0.1: {}
|
default-browser-id@5.0.1: {}
|
||||||
@@ -6693,6 +6782,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@8.0.4: {}
|
diff@8.0.4: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -6892,6 +6983,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
locate-path: 5.0.0
|
||||||
|
path-exists: 4.0.0
|
||||||
|
|
||||||
fontaine@0.8.0:
|
fontaine@0.8.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@capsizecss/unpack': 4.0.0
|
'@capsizecss/unpack': 4.0.0
|
||||||
@@ -7267,6 +7363,10 @@ snapshots:
|
|||||||
pkg-types: 2.3.0
|
pkg-types: 2.3.0
|
||||||
quansync: 0.2.11
|
quansync: 0.2.11
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
lodash.isarguments@3.1.0: {}
|
||||||
@@ -7803,12 +7903,24 @@ snapshots:
|
|||||||
magic-regexp: 0.10.0
|
magic-regexp: 0.10.0
|
||||||
oxc-parser: 0.117.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
oxc-parser: 0.117.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
p-try: 2.2.0
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
p-limit: 2.3.0
|
||||||
|
|
||||||
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
path-key@4.0.0: {}
|
path-key@4.0.0: {}
|
||||||
@@ -7849,6 +7961,8 @@ snapshots:
|
|||||||
exsolve: 1.0.8
|
exsolve: 1.0.8
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
postcss-calc@10.1.1(postcss@8.5.9):
|
postcss-calc@10.1.1(postcss@8.5.9):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.9
|
postcss: 8.5.9
|
||||||
@@ -8132,6 +8246,12 @@ snapshots:
|
|||||||
|
|
||||||
pvutils@1.1.5: {}
|
pvutils@1.1.5: {}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
quansync@0.2.11: {}
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
@@ -8207,6 +8327,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
|
|
||||||
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve-from@5.0.0: {}
|
resolve-from@5.0.0: {}
|
||||||
|
|
||||||
resolve@1.22.12:
|
resolve@1.22.12:
|
||||||
@@ -8313,6 +8437,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
@@ -8849,6 +8975,8 @@ snapshots:
|
|||||||
|
|
||||||
wheel-gestures@2.2.48: {}
|
wheel-gestures@2.2.48: {}
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -8857,6 +8985,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 4.0.0
|
isexe: 4.0.0
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -8891,6 +9025,8 @@ snapshots:
|
|||||||
lib0: 0.2.117
|
lib0: 0.2.117
|
||||||
yjs: 13.6.30
|
yjs: 13.6.30
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
@@ -8899,8 +9035,27 @@ snapshots:
|
|||||||
|
|
||||||
yaml@2.8.3: {}
|
yaml@2.8.3: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
yargs-parser@22.0.0: {}
|
yargs-parser@22.0.0: {}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
yargs@18.0.0:
|
yargs@18.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 9.0.1
|
cliui: 9.0.1
|
||||||
|
|||||||
23
server/api/public/receipts/[token].get.ts
Normal file
23
server/api/public/receipts/[token].get.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PublicBookingReceipt } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import { getBookingReceiptByReceiptToken } from '../../../utils/booking-repository'
|
||||||
|
import { buildAppUrl } from '../../../utils/app-url'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<PublicBookingReceipt> => {
|
||||||
|
const token = getRequiredRouteParam(event, 'token', 'Receipt token')
|
||||||
|
const receipt = await getBookingReceiptByReceiptToken(token)
|
||||||
|
|
||||||
|
if (!receipt) {
|
||||||
|
httpError(404, 'Receipt not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: receipt.booking,
|
||||||
|
receiptUrl: buildAppUrl(event, `/receipt/${receipt.booking.receiptToken}`),
|
||||||
|
seats: receipt.seats.map((seat) => ({
|
||||||
|
...seat,
|
||||||
|
seatUrl: buildAppUrl(event, `/seat/${seat.seatToken}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
34
server/api/public/receipts/[token]/seats/[seatId].patch.ts
Normal file
34
server/api/public/receipts/[token]/seats/[seatId].patch.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { PublicBookingSeatWithUrl } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import { updateBookingSeatShareByReceiptToken } from '../../../../../utils/booking-repository'
|
||||||
|
import { parseSeatShareInput } from '../../../../../utils/bookings'
|
||||||
|
import { buildAppUrl } from '../../../../../utils/app-url'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../../../utils/http'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<{ seat: PublicBookingSeatWithUrl }> => {
|
||||||
|
const token = getRequiredRouteParam(event, 'token', 'Receipt token')
|
||||||
|
const seatId = getRequiredRouteParam(event, 'seatId', 'Seat')
|
||||||
|
const body = await readBody<{
|
||||||
|
shared?: boolean
|
||||||
|
recipientName?: string | null
|
||||||
|
recipientPhone?: string | null
|
||||||
|
}>(event)
|
||||||
|
|
||||||
|
const input = parseSeatShareInput(body)
|
||||||
|
const seat = await updateBookingSeatShareByReceiptToken({
|
||||||
|
receiptToken: token,
|
||||||
|
seatId,
|
||||||
|
...input
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!seat) {
|
||||||
|
httpError(404, 'Seat not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seat: {
|
||||||
|
...seat,
|
||||||
|
seatUrl: buildAppUrl(event, `/seat/${seat.seatToken}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
23
server/api/public/seats/[token].get.ts
Normal file
23
server/api/public/seats/[token].get.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PublicSeatReceipt } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import { getSeatReceiptBySeatToken } from '../../../utils/booking-repository'
|
||||||
|
import { buildAppUrl } from '../../../utils/app-url'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<PublicSeatReceipt> => {
|
||||||
|
const token = getRequiredRouteParam(event, 'token', 'Seat token')
|
||||||
|
const receipt = await getSeatReceiptBySeatToken(token)
|
||||||
|
|
||||||
|
if (!receipt) {
|
||||||
|
httpError(404, 'Seat ticket not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: receipt.booking,
|
||||||
|
receiptUrl: buildAppUrl(event, `/receipt/${receipt.booking.receiptToken}`),
|
||||||
|
seat: {
|
||||||
|
...receipt.seat,
|
||||||
|
seatUrl: buildAppUrl(event, `/seat/${receipt.seat.seatToken}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
BookingMode,
|
BookingMode,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
PublicBooking,
|
PublicBooking,
|
||||||
|
PublicBookingSeat,
|
||||||
|
ReceiptBooking,
|
||||||
TicketType
|
TicketType
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ import { getSqlClient } from './postgres'
|
|||||||
type DbBookingRow = {
|
type DbBookingRow = {
|
||||||
id: string
|
id: string
|
||||||
confirmation_token: string
|
confirmation_token: string
|
||||||
|
receipt_token: string
|
||||||
customer_name: string
|
customer_name: string
|
||||||
customer_phone: string
|
customer_phone: string
|
||||||
booking_mode: BookingMode
|
booking_mode: BookingMode
|
||||||
@@ -34,6 +37,34 @@ type DbBookingRow = {
|
|||||||
confirmed_at: Date | string | null
|
confirmed_at: Date | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DbBookingSeatRow = {
|
||||||
|
id: string
|
||||||
|
seat_number: number | string
|
||||||
|
seat_token: string
|
||||||
|
recipient_name: string | null
|
||||||
|
recipient_phone: string | null
|
||||||
|
shared_at: Date | string | null
|
||||||
|
created_at: Date | string
|
||||||
|
updated_at: Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DbBookingSeatWithBookingRow = DbBookingSeatRow & {
|
||||||
|
booking_id: string
|
||||||
|
confirmation_token: string
|
||||||
|
receipt_token: string
|
||||||
|
customer_name: string
|
||||||
|
customer_phone: string
|
||||||
|
booking_mode: BookingMode
|
||||||
|
quantity: number | string
|
||||||
|
seat_count: number | string
|
||||||
|
ticket_type: TicketType
|
||||||
|
unit_price: number | string
|
||||||
|
total_price: number | string
|
||||||
|
status: BookingStatus | string
|
||||||
|
booking_created_at: Date | string
|
||||||
|
confirmed_at: Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
type DbBookingSettingsRow = {
|
type DbBookingSettingsRow = {
|
||||||
total_tables: number | string | null
|
total_tables: number | string | null
|
||||||
updated_at: Date | string
|
updated_at: Date | string
|
||||||
@@ -47,6 +78,7 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
confirmationToken: row.confirmation_token,
|
confirmationToken: row.confirmation_token,
|
||||||
|
receiptToken: row.receipt_token,
|
||||||
customerName: row.customer_name,
|
customerName: row.customer_name,
|
||||||
customerPhone: row.customer_phone,
|
customerPhone: row.customer_phone,
|
||||||
bookingMode: row.booking_mode,
|
bookingMode: row.booking_mode,
|
||||||
@@ -64,11 +96,41 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
receiptToken: row.receipt_token,
|
||||||
|
customerName: row.customer_name,
|
||||||
|
customerPhone: row.customer_phone,
|
||||||
|
bookingMode: row.booking_mode,
|
||||||
|
quantity: parseInteger(row.quantity),
|
||||||
|
seatCount: parseInteger(row.seat_count),
|
||||||
|
ticketType: row.ticket_type,
|
||||||
|
unitPrice: parseInteger(row.unit_price),
|
||||||
|
totalPrice: parseInteger(row.total_price),
|
||||||
|
status: isBookingStatus(row.status) ? row.status : 'pending',
|
||||||
|
createdAt: toIsoString('booking_created_at' in row ? row.booking_created_at : row.created_at) ?? new Date().toISOString(),
|
||||||
|
confirmedAt: toIsoString(row.confirmed_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBookingSeat(row: DbBookingSeatRow): PublicBookingSeat {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
seatNumber: parseInteger(row.seat_number),
|
||||||
|
seatToken: row.seat_token,
|
||||||
|
recipientName: row.recipient_name,
|
||||||
|
recipientPhone: row.recipient_phone,
|
||||||
|
sharedAt: toIsoString(row.shared_at),
|
||||||
|
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||||
|
updatedAt: toIsoString(row.updated_at) ?? new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
|
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return {
|
return {
|
||||||
totalTables: null,
|
totalTables: null,
|
||||||
totalSeats: null,
|
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +141,29 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function insertBookingSeats(
|
||||||
|
tx: ReturnType<typeof getSqlClient>,
|
||||||
|
bookingId: string,
|
||||||
|
seatCount: number
|
||||||
|
) {
|
||||||
|
for (let seatNumber = 1; seatNumber <= seatCount; seatNumber += 1) {
|
||||||
|
await tx`
|
||||||
|
insert into booking_seats (
|
||||||
|
id,
|
||||||
|
booking_id,
|
||||||
|
seat_number,
|
||||||
|
seat_token
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
${randomUUID()},
|
||||||
|
${bookingId},
|
||||||
|
${seatNumber},
|
||||||
|
${randomToken(24)}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBooking(input: {
|
export async function createBooking(input: {
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
@@ -94,63 +179,75 @@ export async function createBooking(input: {
|
|||||||
}) {
|
}) {
|
||||||
await ensureDatabaseReady()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
const bookingId = randomUUID()
|
||||||
const confirmationToken = randomToken(24)
|
const confirmationToken = randomToken(24)
|
||||||
|
const receiptToken = randomToken(24)
|
||||||
|
|
||||||
const [row] = await sql<DbBookingRow[]>`
|
const row = await sql.begin(async (tx) => {
|
||||||
insert into bookings (
|
const [createdBooking] = await tx<DbBookingRow[]>`
|
||||||
id,
|
insert into bookings (
|
||||||
confirmation_token,
|
id,
|
||||||
customer_name,
|
confirmation_token,
|
||||||
customer_phone,
|
receipt_token,
|
||||||
booking_mode,
|
customer_name,
|
||||||
quantity,
|
customer_phone,
|
||||||
seat_count,
|
booking_mode,
|
||||||
ticket_type,
|
quantity,
|
||||||
unit_price,
|
seat_count,
|
||||||
total_price,
|
ticket_type,
|
||||||
person_in_charge_id,
|
unit_price,
|
||||||
person_in_charge_name,
|
total_price,
|
||||||
person_in_charge_phone_number,
|
person_in_charge_id,
|
||||||
status
|
person_in_charge_name,
|
||||||
)
|
person_in_charge_phone_number,
|
||||||
values (
|
status
|
||||||
${randomUUID()},
|
)
|
||||||
${confirmationToken},
|
values (
|
||||||
${input.customerName},
|
${bookingId},
|
||||||
${input.customerPhone},
|
${confirmationToken},
|
||||||
${input.bookingMode},
|
${receiptToken},
|
||||||
${input.quantity},
|
${input.customerName},
|
||||||
${input.seatCount},
|
${input.customerPhone},
|
||||||
${input.ticketType},
|
${input.bookingMode},
|
||||||
${input.unitPrice},
|
${input.quantity},
|
||||||
${input.totalPrice},
|
${input.seatCount},
|
||||||
${input.personInChargeId},
|
${input.ticketType},
|
||||||
${input.personInChargeName},
|
${input.unitPrice},
|
||||||
${input.personInChargePhoneNumber},
|
${input.totalPrice},
|
||||||
'pending'
|
${input.personInChargeId},
|
||||||
)
|
${input.personInChargeName},
|
||||||
returning
|
${input.personInChargePhoneNumber},
|
||||||
id,
|
'pending'
|
||||||
confirmation_token,
|
)
|
||||||
customer_name,
|
returning
|
||||||
customer_phone,
|
id,
|
||||||
booking_mode,
|
confirmation_token,
|
||||||
quantity,
|
receipt_token,
|
||||||
seat_count,
|
customer_name,
|
||||||
ticket_type,
|
customer_phone,
|
||||||
unit_price,
|
booking_mode,
|
||||||
total_price,
|
quantity,
|
||||||
person_in_charge_id,
|
seat_count,
|
||||||
person_in_charge_name,
|
ticket_type,
|
||||||
person_in_charge_phone_number,
|
unit_price,
|
||||||
status,
|
total_price,
|
||||||
created_at,
|
person_in_charge_id,
|
||||||
confirmed_at
|
person_in_charge_name,
|
||||||
`
|
person_in_charge_phone_number,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
confirmed_at
|
||||||
|
`
|
||||||
|
|
||||||
|
await insertBookingSeats(tx, bookingId, input.seatCount)
|
||||||
|
|
||||||
|
return createdBooking
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
booking: mapBooking(row),
|
booking: mapBooking(row),
|
||||||
confirmationToken
|
confirmationToken,
|
||||||
|
receiptToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +259,7 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
|||||||
select
|
select
|
||||||
id,
|
id,
|
||||||
confirmation_token,
|
confirmation_token,
|
||||||
|
receipt_token,
|
||||||
customer_name,
|
customer_name,
|
||||||
customer_phone,
|
customer_phone,
|
||||||
booking_mode,
|
booking_mode,
|
||||||
@@ -184,6 +282,37 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
|||||||
return row ? mapBooking(row) : null
|
return row ? mapBooking(row) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBookingByReceiptToken(receiptToken: string): Promise<PublicBooking | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [row] = await sql<DbBookingRow[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
confirmation_token,
|
||||||
|
receipt_token,
|
||||||
|
customer_name,
|
||||||
|
customer_phone,
|
||||||
|
booking_mode,
|
||||||
|
quantity,
|
||||||
|
seat_count,
|
||||||
|
ticket_type,
|
||||||
|
unit_price,
|
||||||
|
total_price,
|
||||||
|
person_in_charge_id,
|
||||||
|
person_in_charge_name,
|
||||||
|
person_in_charge_phone_number,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
confirmed_at
|
||||||
|
from bookings
|
||||||
|
where receipt_token = ${receiptToken}
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return row ? mapBooking(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBookings(options?: {
|
export async function listBookings(options?: {
|
||||||
personInChargeId?: string
|
personInChargeId?: string
|
||||||
}): Promise<PublicBooking[]> {
|
}): Promise<PublicBooking[]> {
|
||||||
@@ -195,6 +324,7 @@ export async function listBookings(options?: {
|
|||||||
select
|
select
|
||||||
id,
|
id,
|
||||||
confirmation_token,
|
confirmation_token,
|
||||||
|
receipt_token,
|
||||||
customer_name,
|
customer_name,
|
||||||
customer_phone,
|
customer_phone,
|
||||||
booking_mode,
|
booking_mode,
|
||||||
@@ -217,6 +347,7 @@ export async function listBookings(options?: {
|
|||||||
select
|
select
|
||||||
id,
|
id,
|
||||||
confirmation_token,
|
confirmation_token,
|
||||||
|
receipt_token,
|
||||||
customer_name,
|
customer_name,
|
||||||
customer_phone,
|
customer_phone,
|
||||||
booking_mode,
|
booking_mode,
|
||||||
@@ -238,6 +369,151 @@ export async function listBookings(options?: {
|
|||||||
return rows.map(mapBooking)
|
return rows.map(mapBooking)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const rows = await sql<DbBookingSeatRow[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
seat_number,
|
||||||
|
seat_token,
|
||||||
|
recipient_name,
|
||||||
|
recipient_phone,
|
||||||
|
shared_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
from booking_seats
|
||||||
|
where booking_id = ${bookingId}
|
||||||
|
order by seat_number asc
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows.map(mapBookingSeat)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBookingReceiptByReceiptToken(receiptToken: string): Promise<{
|
||||||
|
booking: ReceiptBooking
|
||||||
|
seats: PublicBookingSeat[]
|
||||||
|
} | null> {
|
||||||
|
const booking = await getBookingByReceiptToken(receiptToken)
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const seats = await listBookingSeats(booking.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: mapReceiptBooking({
|
||||||
|
id: booking.id,
|
||||||
|
confirmation_token: booking.confirmationToken,
|
||||||
|
receipt_token: booking.receiptToken,
|
||||||
|
customer_name: booking.customerName,
|
||||||
|
customer_phone: booking.customerPhone,
|
||||||
|
booking_mode: booking.bookingMode,
|
||||||
|
quantity: booking.quantity,
|
||||||
|
seat_count: booking.seatCount,
|
||||||
|
ticket_type: booking.ticketType,
|
||||||
|
unit_price: booking.unitPrice,
|
||||||
|
total_price: booking.totalPrice,
|
||||||
|
person_in_charge_id: booking.personInChargeId,
|
||||||
|
person_in_charge_name: booking.personInChargeName,
|
||||||
|
person_in_charge_phone_number: booking.personInChargePhoneNumber,
|
||||||
|
status: booking.status,
|
||||||
|
created_at: booking.createdAt,
|
||||||
|
confirmed_at: booking.confirmedAt
|
||||||
|
}),
|
||||||
|
seats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
|
||||||
|
booking: ReceiptBooking
|
||||||
|
seat: PublicBookingSeat
|
||||||
|
} | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [row] = await sql<DbBookingSeatWithBookingRow[]>`
|
||||||
|
select
|
||||||
|
booking_seats.id,
|
||||||
|
booking_seats.seat_number,
|
||||||
|
booking_seats.seat_token,
|
||||||
|
booking_seats.recipient_name,
|
||||||
|
booking_seats.recipient_phone,
|
||||||
|
booking_seats.shared_at,
|
||||||
|
booking_seats.created_at,
|
||||||
|
booking_seats.updated_at,
|
||||||
|
bookings.id as booking_id,
|
||||||
|
bookings.confirmation_token,
|
||||||
|
bookings.receipt_token,
|
||||||
|
bookings.customer_name,
|
||||||
|
bookings.customer_phone,
|
||||||
|
bookings.booking_mode,
|
||||||
|
bookings.quantity,
|
||||||
|
bookings.seat_count,
|
||||||
|
bookings.ticket_type,
|
||||||
|
bookings.unit_price,
|
||||||
|
bookings.total_price,
|
||||||
|
bookings.status,
|
||||||
|
bookings.created_at as booking_created_at,
|
||||||
|
bookings.confirmed_at
|
||||||
|
from booking_seats
|
||||||
|
inner join bookings on bookings.id = booking_seats.booking_id
|
||||||
|
where booking_seats.seat_token = ${seatToken}
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: mapReceiptBooking({
|
||||||
|
...row,
|
||||||
|
id: row.booking_id
|
||||||
|
}),
|
||||||
|
seat: mapBookingSeat(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBookingSeatShareByReceiptToken(input: {
|
||||||
|
receiptToken: string
|
||||||
|
seatId: string
|
||||||
|
shared: boolean
|
||||||
|
recipientName: string | null
|
||||||
|
recipientPhone: string | null
|
||||||
|
}): Promise<PublicBookingSeat | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
const nextSeatToken = input.shared ? null : randomToken(24)
|
||||||
|
|
||||||
|
const [row] = await sql<DbBookingSeatRow[]>`
|
||||||
|
update booking_seats
|
||||||
|
set
|
||||||
|
recipient_name = ${input.shared ? input.recipientName : null},
|
||||||
|
recipient_phone = ${input.shared ? input.recipientPhone : null},
|
||||||
|
shared_at = ${input.shared ? new Date() : null},
|
||||||
|
seat_token = coalesce(${nextSeatToken}, seat_token),
|
||||||
|
updated_at = now()
|
||||||
|
from bookings
|
||||||
|
where booking_seats.booking_id = bookings.id
|
||||||
|
and bookings.receipt_token = ${input.receiptToken}
|
||||||
|
and booking_seats.id = ${input.seatId}
|
||||||
|
returning
|
||||||
|
booking_seats.id,
|
||||||
|
booking_seats.seat_number,
|
||||||
|
booking_seats.seat_token,
|
||||||
|
booking_seats.recipient_name,
|
||||||
|
booking_seats.recipient_phone,
|
||||||
|
booking_seats.shared_at,
|
||||||
|
booking_seats.created_at,
|
||||||
|
booking_seats.updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
return row ? mapBookingSeat(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBookingCapacitySettings(): Promise<BookingCapacitySettings> {
|
export async function getBookingCapacitySettings(): Promise<BookingCapacitySettings> {
|
||||||
await ensureDatabaseReady()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
@@ -298,6 +574,7 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
|||||||
returning
|
returning
|
||||||
id,
|
id,
|
||||||
confirmation_token,
|
confirmation_token,
|
||||||
|
receipt_token,
|
||||||
customer_name,
|
customer_name,
|
||||||
customer_phone,
|
customer_phone,
|
||||||
booking_mode,
|
booking_mode,
|
||||||
|
|||||||
@@ -63,6 +63,29 @@ export function buildBookingMessage(booking: PublicBooking, confirmationUrl: str
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseSeatShareInput(body: {
|
||||||
|
shared?: boolean
|
||||||
|
recipientName?: string | null
|
||||||
|
recipientPhone?: string | null
|
||||||
|
}) {
|
||||||
|
const shared = body.shared
|
||||||
|
const recipientName = normalizeFullName(body.recipientName || '')
|
||||||
|
const recipientPhone = normalizePhoneNumber(body.recipientPhone || '')
|
||||||
|
|
||||||
|
assertBadRequest(typeof shared === 'boolean', 'Shared flag is required')
|
||||||
|
|
||||||
|
if (shared) {
|
||||||
|
assertBadRequest(!recipientName || hasValidFullName(recipientName), 'Recipient name must be at least 2 characters')
|
||||||
|
assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must contain 8 to 15 digits')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shared,
|
||||||
|
recipientName: recipientName || null,
|
||||||
|
recipientPhone: recipientPhone || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseBookingCapacityInput(body: {
|
export function parseBookingCapacityInput(body: {
|
||||||
totalTables?: number | string | null
|
totalTables?: number | string | null
|
||||||
}): Pick<BookingCapacitySettings, 'totalTables'> {
|
}): Pick<BookingCapacitySettings, 'totalTables'> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
|
|||||||
|
|
||||||
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
|
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
|
||||||
|
|
||||||
|
import { randomToken } from './base64url'
|
||||||
import { hashPassword } from './password'
|
import { hashPassword } from './password'
|
||||||
import { getSqlClient } from './postgres'
|
import { getSqlClient } from './postgres'
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ async function initializeDatabase() {
|
|||||||
create table if not exists bookings (
|
create table if not exists bookings (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
confirmation_token text not null unique,
|
confirmation_token text not null unique,
|
||||||
|
receipt_token text not null unique,
|
||||||
customer_name text not null,
|
customer_name text not null,
|
||||||
customer_phone text not null,
|
customer_phone text not null,
|
||||||
booking_mode text not null check (booking_mode in ('table', 'pax')),
|
booking_mode text not null check (booking_mode in ('table', 'pax')),
|
||||||
@@ -83,6 +85,36 @@ async function initializeDatabase() {
|
|||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists receipt_token text
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create unique index if not exists bookings_receipt_token_idx
|
||||||
|
on bookings (receipt_token)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create table if not exists booking_seats (
|
||||||
|
id text primary key,
|
||||||
|
booking_id text not null references bookings(id) on delete cascade,
|
||||||
|
seat_number integer not null check (seat_number >= 1),
|
||||||
|
seat_token text not null unique,
|
||||||
|
recipient_name text,
|
||||||
|
recipient_phone text,
|
||||||
|
shared_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (booking_id, seat_number)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create index if not exists booking_seats_booking_id_idx
|
||||||
|
on booking_seats (booking_id)
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
create table if not exists booking_settings (
|
create table if not exists booking_settings (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
@@ -98,6 +130,74 @@ async function initializeDatabase() {
|
|||||||
on conflict (id) do nothing
|
on conflict (id) do nothing
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
|
||||||
|
select id
|
||||||
|
from bookings
|
||||||
|
where receipt_token is null or receipt_token = ''
|
||||||
|
`
|
||||||
|
|
||||||
|
for (const booking of bookingsMissingReceiptTokens) {
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
receipt_token = ${randomToken(24)},
|
||||||
|
updated_at = now()
|
||||||
|
where id = ${booking.id}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingBookings = await sql<{ id: string, seat_count: number | string }[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
seat_count
|
||||||
|
from bookings
|
||||||
|
`
|
||||||
|
|
||||||
|
for (const booking of existingBookings) {
|
||||||
|
const seatCount = typeof booking.seat_count === 'number'
|
||||||
|
? booking.seat_count
|
||||||
|
: Number.parseInt(booking.seat_count, 10)
|
||||||
|
|
||||||
|
const existingSeatRows = await sql<{ seat_number: number | string }[]>`
|
||||||
|
select seat_number
|
||||||
|
from booking_seats
|
||||||
|
where booking_id = ${booking.id}
|
||||||
|
`
|
||||||
|
|
||||||
|
const existingSeatNumbers = new Set(
|
||||||
|
existingSeatRows.map((seat) => typeof seat.seat_number === 'number'
|
||||||
|
? seat.seat_number
|
||||||
|
: Number.parseInt(seat.seat_number, 10))
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let seatNumber = 1; seatNumber <= seatCount; seatNumber += 1) {
|
||||||
|
if (existingSeatNumbers.has(seatNumber)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
insert into booking_seats (
|
||||||
|
id,
|
||||||
|
booking_id,
|
||||||
|
seat_number,
|
||||||
|
seat_token
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
${randomUUID()},
|
||||||
|
${booking.id},
|
||||||
|
${seatNumber},
|
||||||
|
${randomToken(24)}
|
||||||
|
)
|
||||||
|
on conflict (booking_id, seat_number) do nothing
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column receipt_token set not null
|
||||||
|
`
|
||||||
|
|
||||||
const [existingSuperAdmin] = await sql<{ id: string }[]>`
|
const [existingSuperAdmin] = await sql<{ id: string }[]>`
|
||||||
select id
|
select id
|
||||||
from users
|
from users
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ export type BookingMode = 'table' | 'pax'
|
|||||||
export type TicketType = 'vip' | 'supporter'
|
export type TicketType = 'vip' | 'supporter'
|
||||||
export type BookingStatus = 'pending' | 'confirmed'
|
export type BookingStatus = 'pending' | 'confirmed'
|
||||||
|
|
||||||
|
export const DINNER_EVENT_TITLE = 'DAP JOHOR 60th Anniversary Celebration'
|
||||||
|
export const DINNER_EVENT_DATE_LABEL = 'Saturday, 30 May 2026'
|
||||||
|
export const DINNER_EVENT_TIME_LABEL = '6:30 PM'
|
||||||
|
export const DINNER_EVENT_VENUE = "Yong Peng's Chee Ann Kor"
|
||||||
|
|
||||||
export const BOOKING_MODE_OPTIONS = [
|
export const BOOKING_MODE_OPTIONS = [
|
||||||
{
|
{
|
||||||
value: 'table',
|
value: 'table',
|
||||||
@@ -31,6 +36,7 @@ export const BOOKING_TICKET_CATALOG = [
|
|||||||
export interface PublicBooking {
|
export interface PublicBooking {
|
||||||
id: string
|
id: string
|
||||||
confirmationToken: string
|
confirmationToken: string
|
||||||
|
receiptToken: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
bookingMode: BookingMode
|
bookingMode: BookingMode
|
||||||
@@ -47,6 +53,49 @@ export interface PublicBooking {
|
|||||||
confirmedAt: string | null
|
confirmedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReceiptBooking {
|
||||||
|
id: string
|
||||||
|
receiptToken: string
|
||||||
|
customerName: string
|
||||||
|
customerPhone: string
|
||||||
|
bookingMode: BookingMode
|
||||||
|
quantity: number
|
||||||
|
seatCount: number
|
||||||
|
ticketType: TicketType
|
||||||
|
unitPrice: number
|
||||||
|
totalPrice: number
|
||||||
|
status: BookingStatus
|
||||||
|
createdAt: string
|
||||||
|
confirmedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicBookingSeat {
|
||||||
|
id: string
|
||||||
|
seatNumber: number
|
||||||
|
seatToken: string
|
||||||
|
recipientName: string | null
|
||||||
|
recipientPhone: string | null
|
||||||
|
sharedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicBookingSeatWithUrl extends PublicBookingSeat {
|
||||||
|
seatUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicBookingReceipt {
|
||||||
|
booking: ReceiptBooking
|
||||||
|
receiptUrl: string
|
||||||
|
seats: PublicBookingSeatWithUrl[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicSeatReceipt {
|
||||||
|
booking: ReceiptBooking
|
||||||
|
seat: PublicBookingSeatWithUrl
|
||||||
|
receiptUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookingCapacitySettings {
|
export interface BookingCapacitySettings {
|
||||||
totalTables: number | null
|
totalTables: number | null
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
@@ -109,6 +158,10 @@ export function formatBookingCurrency(value: number) {
|
|||||||
}).format(value)
|
}).format(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSeatLabel(seatNumber: number) {
|
||||||
|
return `Seat ${seatNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
export function calculateBookingInventorySummary(
|
export function calculateBookingInventorySummary(
|
||||||
bookings: Pick<PublicBooking, 'bookingMode' | 'quantity' | 'seatCount' | 'status'>[],
|
bookings: Pick<PublicBooking, 'bookingMode' | 'quantity' | 'seatCount' | 'status'>[],
|
||||||
settings: BookingCapacitySettings
|
settings: BookingCapacitySettings
|
||||||
|
|||||||
Reference in New Issue
Block a user