feat(booking): move event and ticket configuration to database
Replace hardcoded event details and ticket types with dynamic DB records Add booking-config API endpoint to serve active event settings
This commit is contained in:
@@ -213,7 +213,7 @@
|
|||||||
<template #quantity-cell="{ row }">
|
<template #quantity-cell="{ row }">
|
||||||
<div class="space-y-0.5 py-0.5">
|
<div class="space-y-0.5 py-0.5">
|
||||||
<div class="text-sm font-medium text-default">
|
<div class="text-sm font-medium text-default">
|
||||||
{{ ticketLabel(row.original.ticketType) }}
|
{{ ticketLabel(row.original) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
<template #status-cell="{ row }">
|
<template #status-cell="{ row }">
|
||||||
<div class="space-y-1 py-0.5">
|
<div class="space-y-1 py-0.5">
|
||||||
<UBadge
|
<UBadge
|
||||||
:label="getBookingStatusLabel(row.original.status)"
|
:label="getBookingStatusLabel(row.original.status, row.original.statusLabel)"
|
||||||
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
|
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -288,12 +288,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, TicketType } from '~~/shared/booking'
|
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingStatusLabel,
|
getBookingStatusLabel
|
||||||
getTicketCatalogItem
|
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
import { getErrorMessage } from '../../utils/errors'
|
import { getErrorMessage } from '../../utils/errors'
|
||||||
@@ -370,6 +369,7 @@ const filteredBookings = computed(() => {
|
|||||||
booking.personInChargeName,
|
booking.personInChargeName,
|
||||||
booking.personInChargePhoneNumber,
|
booking.personInChargePhoneNumber,
|
||||||
booking.ticketType,
|
booking.ticketType,
|
||||||
|
booking.ticketLabel,
|
||||||
booking.status
|
booking.status
|
||||||
].some((value) => value.toLowerCase().includes(keyword))
|
].some((value) => value.toLowerCase().includes(keyword))
|
||||||
})
|
})
|
||||||
@@ -385,8 +385,8 @@ const confirmedCount = computed(() => {
|
|||||||
|
|
||||||
await refreshBookings()
|
await refreshBookings()
|
||||||
|
|
||||||
function ticketLabel(ticketType: TicketType) {
|
function ticketLabel(booking: PublicBooking) {
|
||||||
return getTicketCatalogItem(ticketType)?.label || ticketType.toUpperCase()
|
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmationPath(booking: PublicBooking) {
|
function confirmationPath(booking: PublicBooking) {
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingStatusLabel,
|
getBookingStatusLabel
|
||||||
getTicketCatalogItem
|
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
import { getErrorMessage } from '../../utils/errors'
|
import { getErrorMessage } from '../../utils/errors'
|
||||||
@@ -32,7 +31,7 @@ try {
|
|||||||
const booking = ref(initialBooking)
|
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(() => booking.value.ticketLabel || 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 receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
|
||||||
const detailRows = computed(() => {
|
const detailRows = computed(() => {
|
||||||
@@ -139,7 +138,7 @@ async function confirmBooking() {
|
|||||||
<p class="text-xs font-medium uppercase tracking-wide text-muted">
|
<p class="text-xs font-medium uppercase tracking-wide text-muted">
|
||||||
Booking status
|
Booking status
|
||||||
</p>
|
</p>
|
||||||
<UBadge :label="getBookingStatusLabel(booking.status)" :color="statusColor" variant="soft" />
|
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel)" :color="statusColor" variant="soft" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-muted">
|
<div class="text-sm text-muted">
|
||||||
|
|||||||
@@ -7,17 +7,10 @@ import {
|
|||||||
normalizePhoneNumber,
|
normalizePhoneNumber,
|
||||||
type PublicContact
|
type PublicContact
|
||||||
} from '~~/shared/auth'
|
} from '~~/shared/auth'
|
||||||
import type { CreateBookingResponse } from '~~/shared/booking'
|
import type { BookingModeOption, CreateBookingResponse, PublicBookingConfig, TicketCatalogItem } from '~~/shared/booking'
|
||||||
import {
|
import {
|
||||||
BOOKING_MODE_OPTIONS,
|
|
||||||
BOOKING_TICKET_CATALOG,
|
|
||||||
DINNER_EVENT_DATE_LABEL,
|
|
||||||
DINNER_EVENT_TIME_LABEL,
|
|
||||||
DINNER_EVENT_TITLE,
|
|
||||||
DINNER_EVENT_VENUE,
|
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getSeatCount,
|
getSeatCount,
|
||||||
getTicketCatalogItem,
|
|
||||||
type BookingMode,
|
type BookingMode,
|
||||||
type TicketType
|
type TicketType
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
@@ -28,25 +21,44 @@ import { getErrorMessage } from '../utils/errors'
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const apiClient = useApiClient()
|
const apiClient = useApiClient()
|
||||||
|
|
||||||
const eventDetails = [
|
const [bookingConfig, contactsResponse] = await Promise.all([
|
||||||
|
apiClient<PublicBookingConfig>('/api/public/booking-config'),
|
||||||
|
apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||||||
|
])
|
||||||
|
|
||||||
|
const eventDetails = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Date',
|
label: 'Date',
|
||||||
value: DINNER_EVENT_DATE_LABEL,
|
value: bookingConfig.event.dateLabel,
|
||||||
icon: 'lucide:calendar-days'
|
icon: 'lucide:calendar-days'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Time',
|
label: 'Time',
|
||||||
value: DINNER_EVENT_TIME_LABEL,
|
value: bookingConfig.event.timeLabel,
|
||||||
icon: 'lucide:clock-6'
|
icon: 'lucide:clock-6'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Venue',
|
label: 'Venue',
|
||||||
value: DINNER_EVENT_VENUE,
|
value: bookingConfig.event.venue,
|
||||||
icon: 'lucide:map-pin'
|
icon: 'lucide:map-pin'
|
||||||
}
|
}
|
||||||
] as const
|
])
|
||||||
|
|
||||||
|
const bookingModeOptions = computed(() => {
|
||||||
|
return bookingConfig.bookingModes.map((mode) => ({
|
||||||
|
value: mode.value,
|
||||||
|
label: mode.label
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const ticketCatalogOptions = computed(() => {
|
||||||
|
return bookingConfig.ticketCatalog.map((ticket) => ({
|
||||||
|
value: ticket.value,
|
||||||
|
label: ticket.label,
|
||||||
|
description: ticket.description
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
|
||||||
const personInCharge = computed(() => {
|
const personInCharge = computed(() => {
|
||||||
return contactsResponse.contacts.map((contact) => ({
|
return contactsResponse.contacts.map((contact) => ({
|
||||||
label: contact.fullName,
|
label: contact.fullName,
|
||||||
@@ -57,9 +69,9 @@ const personInCharge = computed(() => {
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
phone: DEFAULT_PHONE_COUNTRY_CODE,
|
phone: DEFAULT_PHONE_COUNTRY_CODE,
|
||||||
bookingMode: 'table' as BookingMode,
|
bookingMode: (bookingConfig.bookingModes[0]?.value ?? '') as BookingMode,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
ticketType: 'vip' as TicketType
|
ticketType: (bookingConfig.ticketCatalog[0]?.value ?? '') as TicketType
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
|
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
|
||||||
@@ -68,15 +80,22 @@ const selectedPersonInChargeRecord = computed(() => {
|
|||||||
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
|
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? BOOKING_TICKET_CATALOG[0])
|
const selectedBookingMode = computed<BookingModeOption | null>(() => {
|
||||||
|
return bookingConfig.bookingModes.find((mode) => mode.value === form.bookingMode) ?? bookingConfig.bookingModes[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTicket = computed<TicketCatalogItem | null>(() => {
|
||||||
|
return bookingConfig.ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? bookingConfig.ticketCatalog[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
const submittingBooking = ref(false)
|
const submittingBooking = ref(false)
|
||||||
|
|
||||||
const quantityLabel = computed(() => {
|
const quantityLabel = computed(() => {
|
||||||
return form.bookingMode === 'table' ? 'Number of Tables' : 'Number of Seats'
|
return selectedBookingMode.value?.quantityLabel || 'Quantity'
|
||||||
})
|
})
|
||||||
|
|
||||||
const seatCount = computed(() => getSeatCount(form.bookingMode, form.quantity))
|
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
|
||||||
const totalPrice = computed(() => seatCount.value * selectedTicket.value.price)
|
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
|
||||||
|
|
||||||
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
|
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
|
||||||
|
|
||||||
@@ -97,6 +116,14 @@ function validateBooking(state: typeof form): FormError[] {
|
|||||||
errors.push({ name: 'quantity', message: `${quantityLabel.value} must be at least 1.` })
|
errors.push({ name: 'quantity', message: `${quantityLabel.value} must be at least 1.` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!selectedBookingMode.value) {
|
||||||
|
errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedTicket.value) {
|
||||||
|
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
|
||||||
|
}
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +188,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">
|
||||||
{{ DINNER_EVENT_TITLE }}
|
{{ bookingConfig.event.title }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,7 +219,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
|
|
||||||
<UFormField label="Booking Mode" name="bookingMode">
|
<UFormField label="Booking Mode" name="bookingMode">
|
||||||
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
|
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
|
||||||
:items="BOOKING_MODE_OPTIONS" :ui="{
|
:items="bookingModeOptions" :ui="{
|
||||||
fieldset: 'grid grid-cols-2 gap-3',
|
fieldset: 'grid grid-cols-2 gap-3',
|
||||||
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||||||
}" />
|
}" />
|
||||||
@@ -207,7 +234,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
|
|
||||||
<UFormField label="Ticket Category" name="ticketType">
|
<UFormField label="Ticket Category" name="ticketType">
|
||||||
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
|
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
|
||||||
:items="BOOKING_TICKET_CATALOG" :ui="{
|
:items="ticketCatalogOptions" :ui="{
|
||||||
fieldset: 'grid grid-cols-2 gap-3',
|
fieldset: 'grid grid-cols-2 gap-3',
|
||||||
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||||||
}" />
|
}" />
|
||||||
@@ -231,7 +258,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UButton id="getTicketBtn" type="submit" label="Book Now" size="xl"
|
<UButton id="getTicketBtn" type="submit" label="Book Now" size="xl"
|
||||||
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
|
class="w-full justify-center" :disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
|
||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,9 @@
|
|||||||
import type { PublicBookingReceipt, PublicBookingSeatWithUrl } from '~~/shared/booking'
|
import type { PublicBookingReceipt, PublicBookingSeatWithUrl } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DINNER_EVENT_DATE_LABEL,
|
|
||||||
DINNER_EVENT_TIME_LABEL,
|
|
||||||
DINNER_EVENT_TITLE,
|
|
||||||
DINNER_EVENT_VENUE,
|
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingStatusLabel,
|
getBookingStatusLabel,
|
||||||
getSeatLabel,
|
getSeatLabel
|
||||||
getTicketCatalogItem
|
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
import { getErrorMessage } from '../../utils/errors'
|
import { getErrorMessage } from '../../utils/errors'
|
||||||
@@ -51,7 +46,8 @@ try {
|
|||||||
|
|
||||||
const receipt = ref(initialReceipt)
|
const receipt = ref(initialReceipt)
|
||||||
|
|
||||||
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
const eventDetails = computed(() => receipt.value.booking.event)
|
||||||
|
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
|
||||||
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
|
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
|
||||||
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
|
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
|
||||||
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
|
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
|
||||||
@@ -66,7 +62,7 @@ const statusRows = computed(() => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
value: getBookingStatusLabel(receipt.value.booking.status),
|
value: getBookingStatusLabel(receipt.value.booking.status, receipt.value.booking.statusLabel),
|
||||||
isBadge: true
|
isBadge: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -126,15 +122,15 @@ function buildSeatBundleText(
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DINNER_EVENT_TITLE,
|
eventDetails.value.title,
|
||||||
`Guest: ${receipt.value.booking.customerName}`,
|
`Guest: ${receipt.value.booking.customerName}`,
|
||||||
recipientLabel,
|
recipientLabel,
|
||||||
recipientPhoneLabel,
|
recipientPhoneLabel,
|
||||||
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
|
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
|
||||||
`Category: ${ticketLabel.value}`,
|
`Category: ${ticketLabel.value}`,
|
||||||
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
`Date: ${eventDetails.value.dateLabel}`,
|
||||||
`Time: ${DINNER_EVENT_TIME_LABEL}`,
|
`Time: ${eventDetails.value.timeLabel}`,
|
||||||
`Venue: ${DINNER_EVENT_VENUE}`,
|
`Venue: ${eventDetails.value.venue}`,
|
||||||
'',
|
'',
|
||||||
...seats.flatMap((seat) => [
|
...seats.flatMap((seat) => [
|
||||||
`${getSeatLabel(seat.seatNumber)}:`,
|
`${getSeatLabel(seat.seatNumber)}:`,
|
||||||
@@ -222,7 +218,7 @@ async function shareSeats() {
|
|||||||
recipientPhone: shareForm.recipientPhone
|
recipientPhone: shareForm.recipientPhone
|
||||||
})
|
})
|
||||||
const shared = await shareLink({
|
const shared = await shareLink({
|
||||||
title: `${DINNER_EVENT_TITLE} seats`,
|
title: `${eventDetails.value.title} seats`,
|
||||||
text: shareText,
|
text: shareText,
|
||||||
clipboardText: shareText,
|
clipboardText: shareText,
|
||||||
successTitle: 'Seats ready',
|
successTitle: 'Seats ready',
|
||||||
@@ -289,7 +285,7 @@ async function shareSeat(seat: PublicBookingSeatWithUrl) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const shared = await shareLink({
|
const shared = await shareLink({
|
||||||
title: `${DINNER_EVENT_TITLE} ${getSeatLabel(seat.seatNumber)}`,
|
title: `${eventDetails.value.title} ${getSeatLabel(seat.seatNumber)}`,
|
||||||
text: buildSeatBundleText([seat]),
|
text: buildSeatBundleText([seat]),
|
||||||
clipboardText: buildSeatBundleText([seat]),
|
clipboardText: buildSeatBundleText([seat]),
|
||||||
successTitle: 'Seat ready',
|
successTitle: 'Seat ready',
|
||||||
@@ -365,7 +361,7 @@ async function openBatchShare() {
|
|||||||
<div class="space-y-1 text-center">
|
<div class="space-y-1 text-center">
|
||||||
<UBadge label="Ticket Receipt" color="primary" variant="soft" class="rounded-full" />
|
<UBadge label="Ticket Receipt" color="primary" variant="soft" class="rounded-full" />
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
|
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
|
||||||
{{ DINNER_EVENT_TITLE }}
|
{{ eventDetails.title }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,8 @@
|
|||||||
import type { PublicSeatReceipt } from '~~/shared/booking'
|
import type { PublicSeatReceipt } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DINNER_EVENT_DATE_LABEL,
|
|
||||||
DINNER_EVENT_TIME_LABEL,
|
|
||||||
DINNER_EVENT_TITLE,
|
|
||||||
DINNER_EVENT_VENUE,
|
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getSeatLabel,
|
getSeatLabel
|
||||||
getTicketCatalogItem
|
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
import { formatDateTime } from '../../utils/formatters'
|
import { formatDateTime } from '../../utils/formatters'
|
||||||
@@ -31,7 +26,8 @@ try {
|
|||||||
|
|
||||||
const receipt = ref(initialReceipt)
|
const receipt = ref(initialReceipt)
|
||||||
|
|
||||||
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
const eventDetails = computed(() => receipt.value.booking.event)
|
||||||
|
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
|
||||||
const totalFormatted = computed(() => formatBookingCurrency(receipt.value.booking.totalPrice))
|
const totalFormatted = computed(() => formatBookingCurrency(receipt.value.booking.totalPrice))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,7 +40,7 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
|
|||||||
{{ getSeatLabel(receipt.seat.seatNumber) }}
|
{{ getSeatLabel(receipt.seat.seatNumber) }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-muted">
|
<p class="text-sm text-muted">
|
||||||
{{ DINNER_EVENT_TITLE }}
|
{{ eventDetails.title }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,15 +120,15 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
|
|||||||
<div class="space-y-3 rounded-2xl border border-default bg-elevated p-4">
|
<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">
|
<div class="flex items-center gap-3 text-sm text-default">
|
||||||
<UIcon name="i-lucide-calendar-days" class="size-4 text-muted" />
|
<UIcon name="i-lucide-calendar-days" class="size-4 text-muted" />
|
||||||
<span>{{ DINNER_EVENT_DATE_LABEL }}</span>
|
<span>{{ eventDetails.dateLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-sm text-default">
|
<div class="flex items-center gap-3 text-sm text-default">
|
||||||
<UIcon name="i-lucide-clock-6" class="size-4 text-muted" />
|
<UIcon name="i-lucide-clock-6" class="size-4 text-muted" />
|
||||||
<span>{{ DINNER_EVENT_TIME_LABEL }}</span>
|
<span>{{ eventDetails.timeLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-sm text-default">
|
<div class="flex items-center gap-3 text-sm text-default">
|
||||||
<UIcon name="i-lucide-map-pin" class="size-4 text-muted" />
|
<UIcon name="i-lucide-map-pin" class="size-4 text-muted" />
|
||||||
<span>{{ DINNER_EVENT_VENUE }}</span>
|
<span>{{ eventDetails.venue }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
5
server/api/public/booking-config.get.ts
Normal file
5
server/api/public/booking-config.get.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { getPublicBookingConfig } from '../../utils/booking-repository'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
return await getPublicBookingConfig()
|
||||||
|
})
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking'
|
import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking'
|
||||||
|
|
||||||
import { getTicketCatalogItem, getSeatCount } from '~~/shared/booking'
|
import { getSeatCount } from '~~/shared/booking'
|
||||||
|
|
||||||
import { buildAppUrl } from '../../utils/app-url'
|
import { buildAppUrl } from '../../utils/app-url'
|
||||||
import { createBooking } from '../../utils/booking-repository'
|
import {
|
||||||
|
createBooking,
|
||||||
|
getActiveBookingModeOptionByCode,
|
||||||
|
getActiveTicketCatalogItemByCode
|
||||||
|
} from '../../utils/booking-repository'
|
||||||
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
|
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
|
||||||
import { assertBadRequest } from '../../utils/http'
|
import { assertBadRequest } from '../../utils/http'
|
||||||
import { getPublicContactById } from '../../utils/user-repository'
|
import { getPublicContactById } from '../../utils/user-repository'
|
||||||
@@ -20,29 +24,33 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
|
|||||||
}>(event)
|
}>(event)
|
||||||
|
|
||||||
const input = parseCreateBookingInput(body)
|
const input = parseCreateBookingInput(body)
|
||||||
const personInCharge = await getPublicContactById(input.personInChargeId)
|
const [personInCharge, bookingMode, ticket] = await Promise.all([
|
||||||
|
getPublicContactById(input.personInChargeId),
|
||||||
|
getActiveBookingModeOptionByCode(input.bookingMode),
|
||||||
|
getActiveTicketCatalogItemByCode(input.ticketType)
|
||||||
|
])
|
||||||
|
|
||||||
assertBadRequest(personInCharge, 'Selected person in charge is not available')
|
assertBadRequest(personInCharge, 'Selected person in charge is not available')
|
||||||
|
assertBadRequest(bookingMode, 'Booking mode is invalid')
|
||||||
const ticket = getTicketCatalogItem(input.ticketType)
|
|
||||||
|
|
||||||
assertBadRequest(ticket, 'Ticket category is invalid')
|
assertBadRequest(ticket, 'Ticket category is invalid')
|
||||||
|
assertBadRequest(bookingMode.eventId === ticket.eventId, 'Booking mode and ticket category must belong to the same event')
|
||||||
|
|
||||||
const seatCount = getSeatCount(input.bookingMode, input.quantity)
|
const seatCount = getSeatCount(bookingMode, input.quantity)
|
||||||
const totalPrice = seatCount * ticket.price
|
const totalPrice = seatCount * ticket.price
|
||||||
|
|
||||||
const { booking, confirmationToken } = await createBooking({
|
const { booking, confirmationToken } = await createBooking({
|
||||||
|
eventId: bookingMode.eventId,
|
||||||
customerName: input.customerName,
|
customerName: input.customerName,
|
||||||
customerPhone: input.customerPhone,
|
customerPhone: input.customerPhone,
|
||||||
bookingMode: input.bookingMode,
|
bookingModeId: bookingMode.id,
|
||||||
|
bookingMode: bookingMode.value,
|
||||||
quantity: input.quantity,
|
quantity: input.quantity,
|
||||||
seatCount,
|
seatCount,
|
||||||
ticketType: input.ticketType,
|
ticketTypeId: ticket.id,
|
||||||
|
ticketType: ticket.value,
|
||||||
unitPrice: ticket.price,
|
unitPrice: ticket.price,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
personInChargeId: personInCharge.id,
|
personInChargeId: personInCharge.id
|
||||||
personInChargeName: personInCharge.fullName,
|
|
||||||
personInChargePhoneNumber: personInCharge.phoneNumber
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
|
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
BookingModeOption,
|
||||||
BookingCapacitySettings,
|
BookingCapacitySettings,
|
||||||
BookingInventorySummary,
|
BookingInventorySummary,
|
||||||
|
DinnerEvent,
|
||||||
BookingMode,
|
BookingMode,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
|
PublicBookingConfig,
|
||||||
PublicBooking,
|
PublicBooking,
|
||||||
PublicBookingSeat,
|
PublicBookingSeat,
|
||||||
ReceiptBooking,
|
ReceiptBooking,
|
||||||
|
TicketCatalogItem,
|
||||||
TicketType
|
TicketType
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
import { calculateBookingInventorySummary, isBookingMode, isBookingStatus } from '~~/shared/booking'
|
import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus } from '~~/shared/booking'
|
||||||
|
|
||||||
import { randomToken, toIsoString } from './base64url'
|
import { randomToken, toIsoString } from './base64url'
|
||||||
import { ensureDatabaseReady } from './db-init'
|
import { ensureDatabaseReady } from './db-init'
|
||||||
@@ -21,18 +25,30 @@ type DbBookingRow = {
|
|||||||
id: string
|
id: string
|
||||||
confirmation_token: string
|
confirmation_token: string
|
||||||
receipt_token: string
|
receipt_token: string
|
||||||
|
event_id: string
|
||||||
|
event_title: string
|
||||||
|
event_date_label: string
|
||||||
|
event_time_label: string
|
||||||
|
event_venue: string
|
||||||
customer_name: string
|
customer_name: string
|
||||||
customer_phone: string
|
customer_phone: string
|
||||||
|
booking_mode_id: string | null
|
||||||
booking_mode: string
|
booking_mode: string
|
||||||
|
booking_mode_label: string | null
|
||||||
|
booking_mode_seats_per_unit: number | string | null
|
||||||
quantity: number | string
|
quantity: number | string
|
||||||
seat_count: number | string
|
seat_count: number | string
|
||||||
ticket_type: TicketType
|
ticket_type_id: string | null
|
||||||
|
ticket_type: string
|
||||||
|
ticket_label: string | null
|
||||||
|
ticket_description: string | null
|
||||||
unit_price: number | string
|
unit_price: number | string
|
||||||
total_price: number | string
|
total_price: number | string
|
||||||
person_in_charge_id: string
|
person_in_charge_id: string
|
||||||
person_in_charge_name: string
|
person_in_charge_name: string | null
|
||||||
person_in_charge_phone_number: string
|
person_in_charge_phone_number: string | null
|
||||||
status: BookingStatus | string
|
status: BookingStatus | string
|
||||||
|
status_label: string | null
|
||||||
created_at: Date | string
|
created_at: Date | string
|
||||||
confirmed_at: Date | string | null
|
confirmed_at: Date | string | null
|
||||||
}
|
}
|
||||||
@@ -48,56 +64,175 @@ type DbBookingSeatRow = {
|
|||||||
updated_at: Date | string
|
updated_at: Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DbBookingSeatWithBookingRow = DbBookingSeatRow & {
|
type DbBookingSeatWithBookingRow = DbBookingSeatRow & Omit<DbBookingRow, 'id' | 'created_at'> & {
|
||||||
booking_id: string
|
booking_id: string
|
||||||
confirmation_token: string
|
|
||||||
receipt_token: string
|
|
||||||
customer_name: string
|
|
||||||
customer_phone: string
|
|
||||||
booking_mode: string
|
|
||||||
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
|
booking_created_at: Date | string
|
||||||
confirmed_at: Date | string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DbBookingSettingsRow = {
|
type DbBookingSettingsRow = {
|
||||||
|
event_id: string
|
||||||
total_tables: number | string | null
|
total_tables: number | string | null
|
||||||
total_seats: number | string | null
|
total_seats: number | string | null
|
||||||
updated_at: Date | string
|
updated_at: Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DbDinnerEventRow = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
date_label: string
|
||||||
|
time_label: string
|
||||||
|
venue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DbBookingModeOptionRow = {
|
||||||
|
id: string
|
||||||
|
event_id: string
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
quantity_label: string
|
||||||
|
seats_per_unit: number | string
|
||||||
|
sort_order: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DbTicketCatalogItemRow = {
|
||||||
|
id: string
|
||||||
|
event_id: string
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
price: number | string
|
||||||
|
sort_order: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingModeOptionRecord extends BookingModeOption {
|
||||||
|
eventId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketCatalogItemRecord extends TicketCatalogItem {
|
||||||
|
eventId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookingSelectColumns(sql: any) {
|
||||||
|
return sql`
|
||||||
|
bookings.id,
|
||||||
|
bookings.confirmation_token,
|
||||||
|
bookings.receipt_token,
|
||||||
|
dinner_events.id as event_id,
|
||||||
|
dinner_events.title as event_title,
|
||||||
|
dinner_events.date_label as event_date_label,
|
||||||
|
dinner_events.time_label as event_time_label,
|
||||||
|
dinner_events.venue as event_venue,
|
||||||
|
bookings.customer_name,
|
||||||
|
bookings.customer_phone,
|
||||||
|
bookings.booking_mode_id,
|
||||||
|
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
|
||||||
|
booking_modes.label as booking_mode_label,
|
||||||
|
booking_modes.seats_per_unit as booking_mode_seats_per_unit,
|
||||||
|
bookings.quantity,
|
||||||
|
bookings.seat_count,
|
||||||
|
bookings.ticket_type_id,
|
||||||
|
coalesce(ticket_types.code, bookings.ticket_type) as ticket_type,
|
||||||
|
ticket_types.label as ticket_label,
|
||||||
|
ticket_types.description as ticket_description,
|
||||||
|
bookings.unit_price,
|
||||||
|
bookings.total_price,
|
||||||
|
bookings.person_in_charge_id,
|
||||||
|
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
|
||||||
|
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
|
||||||
|
bookings.status,
|
||||||
|
booking_statuses.label as status_label,
|
||||||
|
bookings.created_at,
|
||||||
|
bookings.confirmed_at
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookingJoins(sql: any) {
|
||||||
|
return sql`
|
||||||
|
inner join dinner_events on dinner_events.id = bookings.event_id
|
||||||
|
left join booking_modes on booking_modes.id = bookings.booking_mode_id
|
||||||
|
left join ticket_types on ticket_types.id = bookings.ticket_type_id
|
||||||
|
left join users on users.id = bookings.person_in_charge_id
|
||||||
|
left join booking_statuses on booking_statuses.code = bookings.status
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
function parseInteger(value: number | string) {
|
function parseInteger(value: number | string) {
|
||||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBookingMode(value: string): BookingMode {
|
function mapDinnerEvent(row: DbDinnerEventRow): DinnerEvent {
|
||||||
return isBookingMode(value) ? value : 'seat'
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
dateLabel: row.date_label,
|
||||||
|
timeLabel: row.time_label,
|
||||||
|
venue: row.venue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDinnerEventFromBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): DinnerEvent {
|
||||||
|
return {
|
||||||
|
id: row.event_id,
|
||||||
|
title: row.event_title,
|
||||||
|
dateLabel: row.event_date_label,
|
||||||
|
timeLabel: row.event_time_label,
|
||||||
|
venue: row.event_venue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBookingModeOption(row: DbBookingModeOptionRow): BookingModeOptionRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
eventId: row.event_id,
|
||||||
|
value: row.code,
|
||||||
|
label: row.label,
|
||||||
|
quantityLabel: row.quantity_label,
|
||||||
|
seatsPerUnit: parseInteger(row.seats_per_unit),
|
||||||
|
sortOrder: parseInteger(row.sort_order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTicketCatalogItem(row: DbTicketCatalogItemRow): TicketCatalogItemRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
eventId: row.event_id,
|
||||||
|
value: row.code,
|
||||||
|
label: row.label,
|
||||||
|
description: row.description,
|
||||||
|
price: parseInteger(row.price),
|
||||||
|
sortOrder: parseInteger(row.sort_order)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBooking(row: DbBookingRow): PublicBooking {
|
function mapBooking(row: DbBookingRow): PublicBooking {
|
||||||
const seatCount = parseInteger(row.seat_count)
|
const seatCount = parseInteger(row.seat_count)
|
||||||
|
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
||||||
|
const ticketType = row.ticket_type
|
||||||
|
const bookingMode = row.booking_mode
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
confirmationToken: row.confirmation_token,
|
confirmationToken: row.confirmation_token,
|
||||||
receiptToken: row.receipt_token,
|
receiptToken: row.receipt_token,
|
||||||
|
event: mapDinnerEventFromBooking(row),
|
||||||
customerName: row.customer_name,
|
customerName: row.customer_name,
|
||||||
customerPhone: row.customer_phone,
|
customerPhone: row.customer_phone,
|
||||||
bookingMode: normalizeBookingMode(row.booking_mode),
|
bookingModeId: row.booking_mode_id,
|
||||||
|
bookingMode,
|
||||||
|
bookingModeLabel: row.booking_mode_label || bookingMode,
|
||||||
quantity: parseInteger(row.quantity),
|
quantity: parseInteger(row.quantity),
|
||||||
seatCount,
|
seatCount,
|
||||||
ticketType: row.ticket_type,
|
ticketTypeId: row.ticket_type_id,
|
||||||
|
ticketType,
|
||||||
|
ticketLabel: row.ticket_label || ticketType.toUpperCase(),
|
||||||
|
ticketDescription: row.ticket_description,
|
||||||
unitPrice: parseInteger(row.unit_price),
|
unitPrice: parseInteger(row.unit_price),
|
||||||
totalPrice: parseInteger(row.total_price),
|
totalPrice: parseInteger(row.total_price),
|
||||||
personInChargeId: row.person_in_charge_id,
|
personInChargeId: row.person_in_charge_id,
|
||||||
personInChargeName: row.person_in_charge_name,
|
personInChargeName: row.person_in_charge_name || '',
|
||||||
personInChargePhoneNumber: row.person_in_charge_phone_number,
|
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
||||||
status: isBookingStatus(row.status) ? row.status : 'pending',
|
status,
|
||||||
|
statusLabel: row.status_label || getBookingStatusLabel(status),
|
||||||
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||||
confirmedAt: toIsoString(row.confirmed_at)
|
confirmedAt: toIsoString(row.confirmed_at)
|
||||||
}
|
}
|
||||||
@@ -105,24 +240,59 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
|||||||
|
|
||||||
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
|
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
|
||||||
const seatCount = parseInteger(row.seat_count)
|
const seatCount = parseInteger(row.seat_count)
|
||||||
|
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
||||||
|
const ticketType = row.ticket_type
|
||||||
|
const bookingMode = row.booking_mode
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
receiptToken: row.receipt_token,
|
receiptToken: row.receipt_token,
|
||||||
|
event: mapDinnerEventFromBooking(row),
|
||||||
customerName: row.customer_name,
|
customerName: row.customer_name,
|
||||||
customerPhone: row.customer_phone,
|
customerPhone: row.customer_phone,
|
||||||
bookingMode: normalizeBookingMode(row.booking_mode),
|
bookingModeId: row.booking_mode_id,
|
||||||
|
bookingMode,
|
||||||
|
bookingModeLabel: row.booking_mode_label || bookingMode,
|
||||||
quantity: parseInteger(row.quantity),
|
quantity: parseInteger(row.quantity),
|
||||||
seatCount,
|
seatCount,
|
||||||
ticketType: row.ticket_type,
|
ticketTypeId: row.ticket_type_id,
|
||||||
|
ticketType,
|
||||||
|
ticketLabel: row.ticket_label || ticketType.toUpperCase(),
|
||||||
|
ticketDescription: row.ticket_description,
|
||||||
unitPrice: parseInteger(row.unit_price),
|
unitPrice: parseInteger(row.unit_price),
|
||||||
totalPrice: parseInteger(row.total_price),
|
totalPrice: parseInteger(row.total_price),
|
||||||
status: isBookingStatus(row.status) ? row.status : 'pending',
|
status,
|
||||||
|
statusLabel: row.status_label || getBookingStatusLabel(status),
|
||||||
createdAt: toIsoString('booking_created_at' in row ? row.booking_created_at : row.created_at) ?? new Date().toISOString(),
|
createdAt: toIsoString('booking_created_at' in row ? row.booking_created_at : row.created_at) ?? new Date().toISOString(),
|
||||||
confirmedAt: toIsoString(row.confirmed_at)
|
confirmedAt: toIsoString(row.confirmed_at)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapPublicBookingToReceiptBooking(booking: PublicBooking): ReceiptBooking {
|
||||||
|
return {
|
||||||
|
id: booking.id,
|
||||||
|
receiptToken: booking.receiptToken,
|
||||||
|
event: booking.event,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
bookingModeId: booking.bookingModeId,
|
||||||
|
bookingMode: booking.bookingMode,
|
||||||
|
bookingModeLabel: booking.bookingModeLabel,
|
||||||
|
quantity: booking.quantity,
|
||||||
|
seatCount: booking.seatCount,
|
||||||
|
ticketTypeId: booking.ticketTypeId,
|
||||||
|
ticketType: booking.ticketType,
|
||||||
|
ticketLabel: booking.ticketLabel,
|
||||||
|
ticketDescription: booking.ticketDescription,
|
||||||
|
unitPrice: booking.unitPrice,
|
||||||
|
totalPrice: booking.totalPrice,
|
||||||
|
status: booking.status,
|
||||||
|
statusLabel: booking.statusLabel,
|
||||||
|
createdAt: booking.createdAt,
|
||||||
|
confirmedAt: booking.confirmedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mapBookingSeat(row: DbBookingSeatRow): PublicBookingSeat {
|
function mapBookingSeat(row: DbBookingSeatRow): PublicBookingSeat {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -144,9 +314,7 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalSeats = row.total_seats === null
|
const totalSeats = row.total_seats === null ? null : parseInteger(row.total_seats)
|
||||||
? (row.total_tables === null ? null : parseInteger(row.total_tables) * 10)
|
|
||||||
: parseInteger(row.total_seats)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSeats,
|
totalSeats,
|
||||||
@@ -154,6 +322,115 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPublicBookingConfig(): Promise<PublicBookingConfig> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [event] = await sql<DbDinnerEventRow[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
date_label,
|
||||||
|
time_label,
|
||||||
|
venue
|
||||||
|
from dinner_events
|
||||||
|
where is_active = true
|
||||||
|
order by sort_order asc, created_at asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('No active dinner event is configured.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bookingModes, ticketCatalog] = await Promise.all([
|
||||||
|
sql<DbBookingModeOptionRow[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
event_id,
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
quantity_label,
|
||||||
|
seats_per_unit,
|
||||||
|
sort_order
|
||||||
|
from booking_modes
|
||||||
|
where event_id = ${event.id}
|
||||||
|
and is_active = true
|
||||||
|
order by sort_order asc, label asc
|
||||||
|
`,
|
||||||
|
sql<DbTicketCatalogItemRow[]>`
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
event_id,
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
price,
|
||||||
|
sort_order
|
||||||
|
from ticket_types
|
||||||
|
where event_id = ${event.id}
|
||||||
|
and is_active = true
|
||||||
|
order by sort_order asc, label asc
|
||||||
|
`
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: mapDinnerEvent(event),
|
||||||
|
bookingModes: bookingModes.map(mapBookingModeOption),
|
||||||
|
ticketCatalog: ticketCatalog.map(mapTicketCatalogItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveBookingModeOptionByCode(code: string): Promise<BookingModeOptionRecord | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [row] = await sql<DbBookingModeOptionRow[]>`
|
||||||
|
select
|
||||||
|
booking_modes.id,
|
||||||
|
booking_modes.event_id,
|
||||||
|
booking_modes.code,
|
||||||
|
booking_modes.label,
|
||||||
|
booking_modes.quantity_label,
|
||||||
|
booking_modes.seats_per_unit,
|
||||||
|
booking_modes.sort_order
|
||||||
|
from booking_modes
|
||||||
|
inner join dinner_events on dinner_events.id = booking_modes.event_id
|
||||||
|
where dinner_events.is_active = true
|
||||||
|
and booking_modes.is_active = true
|
||||||
|
and booking_modes.code = ${code}
|
||||||
|
order by booking_modes.sort_order asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return row ? mapBookingModeOption(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveTicketCatalogItemByCode(code: string): Promise<TicketCatalogItemRecord | null> {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
const [row] = await sql<DbTicketCatalogItemRow[]>`
|
||||||
|
select
|
||||||
|
ticket_types.id,
|
||||||
|
ticket_types.event_id,
|
||||||
|
ticket_types.code,
|
||||||
|
ticket_types.label,
|
||||||
|
ticket_types.description,
|
||||||
|
ticket_types.price,
|
||||||
|
ticket_types.sort_order
|
||||||
|
from ticket_types
|
||||||
|
inner join dinner_events on dinner_events.id = ticket_types.event_id
|
||||||
|
where dinner_events.is_active = true
|
||||||
|
and ticket_types.is_active = true
|
||||||
|
and ticket_types.code = ${code}
|
||||||
|
order by ticket_types.sort_order asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return row ? mapTicketCatalogItem(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
async function insertBookingSeats(
|
async function insertBookingSeats(
|
||||||
tx: ReturnType<typeof getSqlClient>,
|
tx: ReturnType<typeof getSqlClient>,
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
@@ -178,17 +455,18 @@ async function insertBookingSeats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createBooking(input: {
|
export async function createBooking(input: {
|
||||||
|
eventId: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
bookingModeId: string
|
||||||
bookingMode: BookingMode
|
bookingMode: BookingMode
|
||||||
quantity: number
|
quantity: number
|
||||||
seatCount: number
|
seatCount: number
|
||||||
|
ticketTypeId: string
|
||||||
ticketType: TicketType
|
ticketType: TicketType
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
totalPrice: number
|
totalPrice: number
|
||||||
personInChargeId: string
|
personInChargeId: string
|
||||||
personInChargeName: string
|
|
||||||
personInChargePhoneNumber: string
|
|
||||||
}) {
|
}) {
|
||||||
await ensureDatabaseReady()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
@@ -198,58 +476,48 @@ export async function createBooking(input: {
|
|||||||
|
|
||||||
const row = await sql.begin(async (tx) => {
|
const row = await sql.begin(async (tx) => {
|
||||||
const [createdBooking] = await tx<DbBookingRow[]>`
|
const [createdBooking] = await tx<DbBookingRow[]>`
|
||||||
|
with inserted_booking as (
|
||||||
insert into bookings (
|
insert into bookings (
|
||||||
id,
|
id,
|
||||||
confirmation_token,
|
confirmation_token,
|
||||||
receipt_token,
|
receipt_token,
|
||||||
|
event_id,
|
||||||
customer_name,
|
customer_name,
|
||||||
customer_phone,
|
customer_phone,
|
||||||
|
booking_mode_id,
|
||||||
booking_mode,
|
booking_mode,
|
||||||
quantity,
|
quantity,
|
||||||
seat_count,
|
seat_count,
|
||||||
|
ticket_type_id,
|
||||||
ticket_type,
|
ticket_type,
|
||||||
unit_price,
|
unit_price,
|
||||||
total_price,
|
total_price,
|
||||||
person_in_charge_id,
|
person_in_charge_id,
|
||||||
person_in_charge_name,
|
|
||||||
person_in_charge_phone_number,
|
|
||||||
status
|
status
|
||||||
)
|
)
|
||||||
values (
|
values (
|
||||||
${bookingId},
|
${bookingId},
|
||||||
${confirmationToken},
|
${confirmationToken},
|
||||||
${receiptToken},
|
${receiptToken},
|
||||||
|
${input.eventId},
|
||||||
${input.customerName},
|
${input.customerName},
|
||||||
${input.customerPhone},
|
${input.customerPhone},
|
||||||
|
${input.bookingModeId},
|
||||||
${input.bookingMode},
|
${input.bookingMode},
|
||||||
${input.quantity},
|
${input.quantity},
|
||||||
${input.seatCount},
|
${input.seatCount},
|
||||||
|
${input.ticketTypeId},
|
||||||
${input.ticketType},
|
${input.ticketType},
|
||||||
${input.unitPrice},
|
${input.unitPrice},
|
||||||
${input.totalPrice},
|
${input.totalPrice},
|
||||||
${input.personInChargeId},
|
${input.personInChargeId},
|
||||||
${input.personInChargeName},
|
|
||||||
${input.personInChargePhoneNumber},
|
|
||||||
'pending'
|
'pending'
|
||||||
)
|
)
|
||||||
returning
|
returning *
|
||||||
id,
|
)
|
||||||
confirmation_token,
|
select ${bookingSelectColumns(tx)}
|
||||||
receipt_token,
|
from inserted_booking as bookings
|
||||||
customer_name,
|
${bookingJoins(tx)}
|
||||||
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
|
|
||||||
`
|
`
|
||||||
|
|
||||||
await insertBookingSeats(tx, bookingId, input.seatCount)
|
await insertBookingSeats(tx, bookingId, input.seatCount)
|
||||||
@@ -269,26 +537,10 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
|||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
|
||||||
const [row] = await sql<DbBookingRow[]>`
|
const [row] = await sql<DbBookingRow[]>`
|
||||||
select
|
select ${bookingSelectColumns(sql)}
|
||||||
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
|
from bookings
|
||||||
where confirmation_token = ${confirmationToken}
|
${bookingJoins(sql)}
|
||||||
|
where bookings.confirmation_token = ${confirmationToken}
|
||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -300,26 +552,10 @@ export async function getBookingByReceiptToken(receiptToken: string): Promise<Pu
|
|||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
|
||||||
const [row] = await sql<DbBookingRow[]>`
|
const [row] = await sql<DbBookingRow[]>`
|
||||||
select
|
select ${bookingSelectColumns(sql)}
|
||||||
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
|
from bookings
|
||||||
where receipt_token = ${receiptToken}
|
${bookingJoins(sql)}
|
||||||
|
where bookings.receipt_token = ${receiptToken}
|
||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -334,49 +570,19 @@ export async function listBookings(options?: {
|
|||||||
|
|
||||||
const rows = options?.personInChargeId
|
const rows = options?.personInChargeId
|
||||||
? await sql<DbBookingRow[]>`
|
? await sql<DbBookingRow[]>`
|
||||||
select
|
select ${bookingSelectColumns(sql)}
|
||||||
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
|
from bookings
|
||||||
where person_in_charge_id = ${options.personInChargeId}
|
${bookingJoins(sql)}
|
||||||
order by created_at desc
|
where dinner_events.is_active = true
|
||||||
|
and bookings.person_in_charge_id = ${options.personInChargeId}
|
||||||
|
order by bookings.created_at desc
|
||||||
`
|
`
|
||||||
: await sql<DbBookingRow[]>`
|
: await sql<DbBookingRow[]>`
|
||||||
select
|
select ${bookingSelectColumns(sql)}
|
||||||
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
|
from bookings
|
||||||
order by created_at desc
|
${bookingJoins(sql)}
|
||||||
|
where dinner_events.is_active = true
|
||||||
|
order by bookings.created_at desc
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows.map(mapBooking)
|
return rows.map(mapBooking)
|
||||||
@@ -417,25 +623,7 @@ export async function getBookingReceiptByReceiptToken(receiptToken: string): Pro
|
|||||||
const seats = await listBookingSeats(booking.id)
|
const seats = await listBookingSeats(booking.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
booking: mapReceiptBooking({
|
booking: mapPublicBookingToReceiptBooking(booking),
|
||||||
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
|
seats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,19 +648,35 @@ export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
|
|||||||
bookings.id as booking_id,
|
bookings.id as booking_id,
|
||||||
bookings.confirmation_token,
|
bookings.confirmation_token,
|
||||||
bookings.receipt_token,
|
bookings.receipt_token,
|
||||||
|
dinner_events.id as event_id,
|
||||||
|
dinner_events.title as event_title,
|
||||||
|
dinner_events.date_label as event_date_label,
|
||||||
|
dinner_events.time_label as event_time_label,
|
||||||
|
dinner_events.venue as event_venue,
|
||||||
bookings.customer_name,
|
bookings.customer_name,
|
||||||
bookings.customer_phone,
|
bookings.customer_phone,
|
||||||
bookings.booking_mode,
|
bookings.booking_mode_id,
|
||||||
|
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
|
||||||
|
booking_modes.label as booking_mode_label,
|
||||||
|
booking_modes.seats_per_unit as booking_mode_seats_per_unit,
|
||||||
bookings.quantity,
|
bookings.quantity,
|
||||||
bookings.seat_count,
|
bookings.seat_count,
|
||||||
bookings.ticket_type,
|
bookings.ticket_type_id,
|
||||||
|
coalesce(ticket_types.code, bookings.ticket_type) as ticket_type,
|
||||||
|
ticket_types.label as ticket_label,
|
||||||
|
ticket_types.description as ticket_description,
|
||||||
bookings.unit_price,
|
bookings.unit_price,
|
||||||
bookings.total_price,
|
bookings.total_price,
|
||||||
|
bookings.person_in_charge_id,
|
||||||
|
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
|
||||||
|
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
|
||||||
bookings.status,
|
bookings.status,
|
||||||
|
booking_statuses.label as status_label,
|
||||||
bookings.created_at as booking_created_at,
|
bookings.created_at as booking_created_at,
|
||||||
bookings.confirmed_at
|
bookings.confirmed_at
|
||||||
from booking_seats
|
from booking_seats
|
||||||
inner join bookings on bookings.id = booking_seats.booking_id
|
inner join bookings on bookings.id = booking_seats.booking_id
|
||||||
|
${bookingJoins(sql)}
|
||||||
where booking_seats.seat_token = ${seatToken}
|
where booking_seats.seat_token = ${seatToken}
|
||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
@@ -533,11 +737,14 @@ export async function getBookingCapacitySettings(): Promise<BookingCapacitySetti
|
|||||||
|
|
||||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||||
select
|
select
|
||||||
|
booking_settings.event_id,
|
||||||
total_tables,
|
total_tables,
|
||||||
total_seats,
|
total_seats,
|
||||||
updated_at
|
booking_settings.updated_at
|
||||||
from booking_settings
|
from booking_settings
|
||||||
where id = 'default'
|
inner join dinner_events on dinner_events.id = booking_settings.event_id
|
||||||
|
where dinner_events.is_active = true
|
||||||
|
order by dinner_events.sort_order asc
|
||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -555,11 +762,14 @@ export async function updateBookingCapacitySettings(input: {
|
|||||||
set
|
set
|
||||||
total_seats = ${input.totalSeats},
|
total_seats = ${input.totalSeats},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = 'default'
|
from dinner_events
|
||||||
|
where booking_settings.event_id = dinner_events.id
|
||||||
|
and dinner_events.is_active = true
|
||||||
returning
|
returning
|
||||||
|
booking_settings.event_id,
|
||||||
total_tables,
|
total_tables,
|
||||||
total_seats,
|
total_seats,
|
||||||
updated_at
|
booking_settings.updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
return mapBookingCapacitySettings(row)
|
return mapBookingCapacitySettings(row)
|
||||||
@@ -579,31 +789,19 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
|||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
|
||||||
const [row] = await sql<DbBookingRow[]>`
|
const [row] = await sql<DbBookingRow[]>`
|
||||||
update bookings
|
with updated_booking as (
|
||||||
set
|
update bookings
|
||||||
status = 'confirmed',
|
set
|
||||||
confirmed_at = now(),
|
status = 'confirmed',
|
||||||
updated_at = now()
|
confirmed_at = now(),
|
||||||
where confirmation_token = ${confirmationToken}
|
updated_at = now()
|
||||||
and status = 'pending'
|
where confirmation_token = ${confirmationToken}
|
||||||
returning
|
and status = 'pending'
|
||||||
id,
|
returning *
|
||||||
confirmation_token,
|
)
|
||||||
receipt_token,
|
select ${bookingSelectColumns(sql)}
|
||||||
customer_name,
|
from updated_booking as bookings
|
||||||
customer_phone,
|
${bookingJoins(sql)}
|
||||||
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
|
|
||||||
`
|
`
|
||||||
|
|
||||||
if (row) {
|
if (row) {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
|
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency
|
||||||
getTicketCatalogItem,
|
|
||||||
isBookingMode,
|
|
||||||
isTicketType
|
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
||||||
|
|
||||||
@@ -21,15 +18,15 @@ export function parseCreateBookingInput(body: {
|
|||||||
const customerName = normalizeFullName(body.customerName || '')
|
const customerName = normalizeFullName(body.customerName || '')
|
||||||
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
||||||
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
|
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
|
||||||
const ticketType = body.ticketType
|
const ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : body.ticketType
|
||||||
const quantity = Number(body.quantity)
|
const quantity = Number(body.quantity)
|
||||||
const personInChargeId = (body.personInChargeId || '').trim()
|
const personInChargeId = (body.personInChargeId || '').trim()
|
||||||
|
|
||||||
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
|
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
|
||||||
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
|
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
|
||||||
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
|
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
|
||||||
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
|
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
|
||||||
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
|
assertBadRequest(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
|
||||||
assertBadRequest(personInChargeId, 'Person in charge is required')
|
assertBadRequest(personInChargeId, 'Person in charge is required')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -43,16 +40,13 @@ export function parseCreateBookingInput(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
||||||
const ticket = getTicketCatalogItem(booking.ticketType)
|
|
||||||
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
|
`I'd like to book tickets for the ${booking.event.title}.`,
|
||||||
'',
|
'',
|
||||||
`Name: ${booking.customerName}`,
|
`Name: ${booking.customerName}`,
|
||||||
`Phone Number: ${booking.customerPhone}`,
|
`Phone Number: ${booking.customerPhone}`,
|
||||||
`Seats: ${booking.seatCount}`,
|
`Seats: ${booking.seatCount}`,
|
||||||
`Ticket Category: ${ticketLabel}`,
|
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
|
||||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||||
'',
|
'',
|
||||||
'PIC confirmation link:',
|
'PIC confirmation link:',
|
||||||
|
|||||||
@@ -62,23 +62,205 @@ async function initializeDatabase() {
|
|||||||
on user_passkeys (user_id)
|
on user_passkeys (user_id)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create table if not exists dinner_events (
|
||||||
|
id text primary key,
|
||||||
|
title text not null,
|
||||||
|
date_label text not null,
|
||||||
|
time_label text not null,
|
||||||
|
venue text not null,
|
||||||
|
is_active boolean not null default false,
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create unique index if not exists dinner_events_single_active_idx
|
||||||
|
on dinner_events (is_active)
|
||||||
|
where is_active = true
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
insert into dinner_events (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
date_label,
|
||||||
|
time_label,
|
||||||
|
venue,
|
||||||
|
is_active,
|
||||||
|
sort_order
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
'dap-johor-60',
|
||||||
|
'DAP JOHOR 60th Anniversary Celebration',
|
||||||
|
'Saturday, 30 May 2026',
|
||||||
|
'6:30 PM',
|
||||||
|
'Yong Peng''s Chee Ann Kor',
|
||||||
|
true,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update dinner_events
|
||||||
|
set
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now()
|
||||||
|
where id = 'dap-johor-60'
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from dinner_events
|
||||||
|
where is_active = true
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create table if not exists booking_modes (
|
||||||
|
id text primary key,
|
||||||
|
event_id text not null references dinner_events(id) on delete cascade,
|
||||||
|
code text not null,
|
||||||
|
label text not null,
|
||||||
|
quantity_label text not null,
|
||||||
|
seats_per_unit integer not null check (seats_per_unit >= 1),
|
||||||
|
is_active boolean not null default true,
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (event_id, code)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
insert into booking_modes (
|
||||||
|
id,
|
||||||
|
event_id,
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
quantity_label,
|
||||||
|
seats_per_unit,
|
||||||
|
is_active,
|
||||||
|
sort_order
|
||||||
|
)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
'dap-johor-60-table',
|
||||||
|
'dap-johor-60',
|
||||||
|
'table',
|
||||||
|
'Table (10 seats)',
|
||||||
|
'Number of Tables',
|
||||||
|
10,
|
||||||
|
true,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'dap-johor-60-seat',
|
||||||
|
'dap-johor-60',
|
||||||
|
'seat',
|
||||||
|
'Seat',
|
||||||
|
'Number of Seats',
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
on conflict (event_id, code) do nothing
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create table if not exists ticket_types (
|
||||||
|
id text primary key,
|
||||||
|
event_id text not null references dinner_events(id) on delete cascade,
|
||||||
|
code text not null,
|
||||||
|
label text not null,
|
||||||
|
description text not null,
|
||||||
|
price integer not null check (price >= 0),
|
||||||
|
is_active boolean not null default true,
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (event_id, code)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
insert into ticket_types (
|
||||||
|
id,
|
||||||
|
event_id,
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
price,
|
||||||
|
is_active,
|
||||||
|
sort_order
|
||||||
|
)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
'dap-johor-60-vip',
|
||||||
|
'dap-johor-60',
|
||||||
|
'vip',
|
||||||
|
'VIP',
|
||||||
|
'RM150 / seat',
|
||||||
|
150,
|
||||||
|
true,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'dap-johor-60-supporter',
|
||||||
|
'dap-johor-60',
|
||||||
|
'supporter',
|
||||||
|
'Supporter',
|
||||||
|
'RM60 / seat',
|
||||||
|
60,
|
||||||
|
true,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
on conflict (event_id, code) do nothing
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create table if not exists booking_statuses (
|
||||||
|
code text primary key,
|
||||||
|
label text not null,
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
insert into booking_statuses (
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
sort_order
|
||||||
|
)
|
||||||
|
values
|
||||||
|
('pending', 'Pending PIC confirmation', 1),
|
||||||
|
('confirmed', 'Confirmed', 2)
|
||||||
|
on conflict (code) do nothing
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
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,
|
receipt_token text not null unique,
|
||||||
|
event_id text references dinner_events(id) on delete restrict,
|
||||||
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', 'seat')),
|
booking_mode_id text references booking_modes(id) on delete restrict,
|
||||||
|
booking_mode text not null,
|
||||||
quantity integer not null check (quantity >= 1),
|
quantity integer not null check (quantity >= 1),
|
||||||
seat_count integer not null check (seat_count >= 1),
|
seat_count integer not null check (seat_count >= 1),
|
||||||
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
|
ticket_type_id text references ticket_types(id) on delete restrict,
|
||||||
|
ticket_type text not null,
|
||||||
unit_price integer not null check (unit_price >= 0),
|
unit_price integer not null check (unit_price >= 0),
|
||||||
total_price integer not null check (total_price >= 0),
|
total_price integer not null check (total_price >= 0),
|
||||||
person_in_charge_id text not null references users(id) on delete restrict,
|
person_in_charge_id text not null references users(id) on delete restrict,
|
||||||
person_in_charge_name text not null,
|
person_in_charge_name text not null,
|
||||||
person_in_charge_phone_number text not null,
|
person_in_charge_phone_number text not null,
|
||||||
status text not null check (status in ('pending', 'confirmed')) default 'pending',
|
status text not null default 'pending',
|
||||||
confirmed_at timestamptz,
|
confirmed_at timestamptz,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
updated_at timestamptz not null default now()
|
updated_at timestamptz not null default now()
|
||||||
@@ -90,11 +272,41 @@ async function initializeDatabase() {
|
|||||||
add column if not exists receipt_token text
|
add column if not exists receipt_token text
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists event_id text
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists booking_mode_id text
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists ticket_type_id text
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
create unique index if not exists bookings_receipt_token_idx
|
create unique index if not exists bookings_receipt_token_idx
|
||||||
on bookings (receipt_token)
|
on bookings (receipt_token)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create index if not exists bookings_event_id_idx
|
||||||
|
on bookings (event_id)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create index if not exists bookings_booking_mode_id_idx
|
||||||
|
on bookings (booking_mode_id)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create index if not exists bookings_ticket_type_id_idx
|
||||||
|
on bookings (ticket_type_id)
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
create table if not exists booking_seats (
|
create table if not exists booking_seats (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
@@ -118,21 +330,28 @@ async function initializeDatabase() {
|
|||||||
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,
|
||||||
|
event_id text references dinner_events(id) on delete cascade,
|
||||||
total_tables integer,
|
total_tables integer,
|
||||||
total_seats integer,
|
total_seats integer,
|
||||||
updated_at timestamptz not null default now()
|
updated_at timestamptz not null default now()
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table booking_settings
|
||||||
|
add column if not exists event_id text
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
alter table booking_settings
|
alter table booking_settings
|
||||||
add column if not exists total_seats integer
|
add column if not exists total_seats integer
|
||||||
`
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
insert into booking_settings (id)
|
insert into booking_settings (id, event_id)
|
||||||
values ('default')
|
values ('default', 'dap-johor-60')
|
||||||
on conflict (id) do nothing
|
on conflict (id) do update
|
||||||
|
set event_id = coalesce(booking_settings.event_id, excluded.event_id)
|
||||||
`
|
`
|
||||||
|
|
||||||
const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
|
const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
|
||||||
@@ -156,12 +375,6 @@ async function initializeDatabase() {
|
|||||||
drop constraint if exists bookings_booking_mode_check
|
drop constraint if exists bookings_booking_mode_check
|
||||||
`
|
`
|
||||||
|
|
||||||
await sql`
|
|
||||||
alter table bookings
|
|
||||||
add constraint bookings_booking_mode_check
|
|
||||||
check (booking_mode in ('table', 'pax', 'seat'))
|
|
||||||
`
|
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
update bookings
|
update bookings
|
||||||
set
|
set
|
||||||
@@ -177,14 +390,203 @@ async function initializeDatabase() {
|
|||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
alter table bookings
|
alter table bookings
|
||||||
add constraint bookings_booking_mode_check
|
drop constraint if exists bookings_ticket_type_check
|
||||||
check (booking_mode in ('table', 'seat'))
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_status_check
|
||||||
|
`
|
||||||
|
|
||||||
|
const [activeEvent] = await sql<{ id: string }[]>`
|
||||||
|
select id
|
||||||
|
from dinner_events
|
||||||
|
where is_active = true
|
||||||
|
order by sort_order asc, created_at asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (activeEvent) {
|
||||||
|
await sql`
|
||||||
|
update booking_settings
|
||||||
|
set
|
||||||
|
event_id = ${activeEvent.id},
|
||||||
|
updated_at = now()
|
||||||
|
where event_id is null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
event_id = ${activeEvent.id},
|
||||||
|
updated_at = now()
|
||||||
|
where event_id is null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
booking_mode_id = booking_modes.id,
|
||||||
|
updated_at = now()
|
||||||
|
from booking_modes
|
||||||
|
where bookings.booking_mode_id is null
|
||||||
|
and booking_modes.event_id = bookings.event_id
|
||||||
|
and booking_modes.code = bookings.booking_mode
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
ticket_type_id = ticket_types.id,
|
||||||
|
updated_at = now()
|
||||||
|
from ticket_types
|
||||||
|
where bookings.ticket_type_id is null
|
||||||
|
and ticket_types.event_id = bookings.event_id
|
||||||
|
and ticket_types.code = bookings.ticket_type
|
||||||
|
`
|
||||||
|
|
||||||
|
const [fallbackBookingMode] = await sql<{ id: string, code: string }[]>`
|
||||||
|
select id, code
|
||||||
|
from booking_modes
|
||||||
|
where event_id = ${activeEvent.id}
|
||||||
|
and is_active = true
|
||||||
|
order by sort_order asc, created_at asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (fallbackBookingMode) {
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
booking_mode_id = ${fallbackBookingMode.id},
|
||||||
|
booking_mode = ${fallbackBookingMode.code},
|
||||||
|
updated_at = now()
|
||||||
|
where booking_mode_id is null
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fallbackTicketType] = await sql<{ id: string, code: string }[]>`
|
||||||
|
select id, code
|
||||||
|
from ticket_types
|
||||||
|
where event_id = ${activeEvent.id}
|
||||||
|
and is_active = true
|
||||||
|
order by sort_order asc, created_at asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (fallbackTicketType) {
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
ticket_type_id = ${fallbackTicketType.id},
|
||||||
|
ticket_type = ${fallbackTicketType.code},
|
||||||
|
updated_at = now()
|
||||||
|
where ticket_type_id is null
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
create unique index if not exists booking_settings_event_id_idx
|
||||||
|
on booking_settings (event_id)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column person_in_charge_name drop not null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column person_in_charge_phone_number drop not null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column event_id set not null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column booking_mode_id set not null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
alter column ticket_type_id set not null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table booking_settings
|
||||||
|
alter column event_id set not null
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_event_id_fkey
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add constraint bookings_event_id_fkey
|
||||||
|
foreign key (event_id) references dinner_events(id) on delete restrict
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_booking_mode_id_fkey
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add constraint bookings_booking_mode_id_fkey
|
||||||
|
foreign key (booking_mode_id) references booking_modes(id) on delete restrict
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_ticket_type_id_fkey
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add constraint bookings_ticket_type_id_fkey
|
||||||
|
foreign key (ticket_type_id) references ticket_types(id) on delete restrict
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_status_fkey
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add constraint bookings_status_fkey
|
||||||
|
foreign key (status) references booking_statuses(code) on delete restrict
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table booking_settings
|
||||||
|
drop constraint if exists booking_settings_event_id_fkey
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table booking_settings
|
||||||
|
add constraint booking_settings_event_id_fkey
|
||||||
|
foreign key (event_id) references dinner_events(id) on delete cascade
|
||||||
`
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
update booking_settings
|
update booking_settings
|
||||||
set
|
set
|
||||||
total_seats = total_tables * 10,
|
total_seats = total_tables * coalesce((
|
||||||
|
select booking_modes.seats_per_unit
|
||||||
|
from booking_modes
|
||||||
|
where booking_modes.event_id = booking_settings.event_id
|
||||||
|
and booking_modes.code = 'table'
|
||||||
|
order by booking_modes.sort_order asc
|
||||||
|
limit 1
|
||||||
|
), 1),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where total_seats is null
|
where total_seats is null
|
||||||
and total_tables is not null
|
and total_tables is not null
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export function getSqlClient() {
|
|||||||
sqlClient = postgres(
|
sqlClient = postgres(
|
||||||
config.databaseUrl || 'postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system',
|
config.databaseUrl || 'postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system',
|
||||||
{
|
{
|
||||||
max: 10
|
max: 10,
|
||||||
|
onnotice: false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import type { H3Event } from 'h3'
|
|||||||
import type { PublicBooking, WhatsAppDeliveryResult } from '~~/shared/booking'
|
import type { PublicBooking, WhatsAppDeliveryResult } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DINNER_EVENT_DATE_LABEL,
|
formatBookingCurrency
|
||||||
DINNER_EVENT_TIME_LABEL,
|
|
||||||
DINNER_EVENT_TITLE,
|
|
||||||
DINNER_EVENT_VENUE,
|
|
||||||
formatBookingCurrency,
|
|
||||||
getTicketCatalogItem
|
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
import { normalizePhoneNumber } from '~~/shared/auth'
|
import { normalizePhoneNumber } from '~~/shared/auth'
|
||||||
|
|
||||||
@@ -29,22 +24,20 @@ export function buildWhatsAppDeepLink(phoneNumber: string, message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildBookingTicketReceiptMessage(event: H3Event, booking: PublicBooking) {
|
export function buildBookingTicketReceiptMessage(event: H3Event, booking: PublicBooking) {
|
||||||
const ticket = getTicketCatalogItem(booking.ticketType)
|
|
||||||
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
|
|
||||||
const receiptUrl = buildAppUrl(event, `/receipt/${booking.receiptToken}`)
|
const receiptUrl = buildAppUrl(event, `/receipt/${booking.receiptToken}`)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DINNER_EVENT_TITLE,
|
booking.event.title,
|
||||||
'',
|
'',
|
||||||
`Hi ${booking.customerName}, your ticket receipt has been confirmed.`,
|
`Hi ${booking.customerName}, your ticket receipt has been confirmed.`,
|
||||||
'',
|
'',
|
||||||
`Receipt: ${receiptUrl}`,
|
`Receipt: ${receiptUrl}`,
|
||||||
`Seats: ${booking.seatCount}`,
|
`Seats: ${booking.seatCount}`,
|
||||||
`Ticket Category: ${ticketLabel}`,
|
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
|
||||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||||
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
`Date: ${booking.event.dateLabel}`,
|
||||||
`Time: ${DINNER_EVENT_TIME_LABEL}`,
|
`Time: ${booking.event.timeLabel}`,
|
||||||
`Venue: ${DINNER_EVENT_VENUE}`,
|
`Venue: ${booking.event.venue}`,
|
||||||
'',
|
'',
|
||||||
'Please present the QR code from the receipt at the event.'
|
'Please present the QR code from the receipt at the event.'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|||||||
@@ -1,56 +1,62 @@
|
|||||||
export type BookingMode = 'table' | 'seat'
|
export type BookingMode = string
|
||||||
export type TicketType = 'vip' | 'supporter'
|
export type TicketType = string
|
||||||
export type BookingStatus = 'pending' | 'confirmed'
|
export type BookingStatus = 'pending' | 'confirmed'
|
||||||
|
|
||||||
export const DINNER_EVENT_TITLE = 'DAP JOHOR 60th Anniversary Celebration'
|
export interface DinnerEvent {
|
||||||
export const DINNER_EVENT_DATE_LABEL = 'Saturday, 30 May 2026'
|
id: string
|
||||||
export const DINNER_EVENT_TIME_LABEL = '6:30 PM'
|
title: string
|
||||||
export const DINNER_EVENT_VENUE = "Yong Peng's Chee Ann Kor"
|
dateLabel: string
|
||||||
|
timeLabel: string
|
||||||
|
venue: string
|
||||||
|
}
|
||||||
|
|
||||||
export const TABLE_SEAT_COUNT = 10
|
export interface BookingModeOption {
|
||||||
|
id: string
|
||||||
|
value: BookingMode
|
||||||
|
label: string
|
||||||
|
quantityLabel: string
|
||||||
|
seatsPerUnit: number
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
export const BOOKING_MODE_OPTIONS = [
|
export interface TicketCatalogItem {
|
||||||
{
|
id: string
|
||||||
value: 'table',
|
value: TicketType
|
||||||
label: `Table (${TABLE_SEAT_COUNT} seats)`
|
label: string
|
||||||
},
|
description: string
|
||||||
{
|
price: number
|
||||||
value: 'seat',
|
sortOrder: number
|
||||||
label: 'Seat'
|
}
|
||||||
}
|
|
||||||
] satisfies Array<{ value: BookingMode, label: string }>
|
|
||||||
|
|
||||||
export const BOOKING_TICKET_CATALOG = [
|
export interface PublicBookingConfig {
|
||||||
{
|
event: DinnerEvent
|
||||||
value: 'vip',
|
bookingModes: BookingModeOption[]
|
||||||
label: 'VIP',
|
ticketCatalog: TicketCatalogItem[]
|
||||||
description: 'RM150 / seat',
|
}
|
||||||
price: 150
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'supporter',
|
|
||||||
label: 'Supporter',
|
|
||||||
description: 'RM60 / seat',
|
|
||||||
price: 60
|
|
||||||
}
|
|
||||||
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
|
|
||||||
|
|
||||||
export interface PublicBooking {
|
export interface PublicBooking {
|
||||||
id: string
|
id: string
|
||||||
confirmationToken: string
|
confirmationToken: string
|
||||||
receiptToken: string
|
receiptToken: string
|
||||||
|
event: DinnerEvent
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
bookingModeId: string | null
|
||||||
bookingMode: BookingMode
|
bookingMode: BookingMode
|
||||||
|
bookingModeLabel: string
|
||||||
quantity: number
|
quantity: number
|
||||||
seatCount: number
|
seatCount: number
|
||||||
|
ticketTypeId: string | null
|
||||||
ticketType: TicketType
|
ticketType: TicketType
|
||||||
|
ticketLabel: string
|
||||||
|
ticketDescription: string | null
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
totalPrice: number
|
totalPrice: number
|
||||||
personInChargeId: string
|
personInChargeId: string
|
||||||
personInChargeName: string
|
personInChargeName: string
|
||||||
personInChargePhoneNumber: string
|
personInChargePhoneNumber: string
|
||||||
status: BookingStatus
|
status: BookingStatus
|
||||||
|
statusLabel: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
confirmedAt: string | null
|
confirmedAt: string | null
|
||||||
}
|
}
|
||||||
@@ -58,15 +64,22 @@ export interface PublicBooking {
|
|||||||
export interface ReceiptBooking {
|
export interface ReceiptBooking {
|
||||||
id: string
|
id: string
|
||||||
receiptToken: string
|
receiptToken: string
|
||||||
|
event: DinnerEvent
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
bookingModeId: string | null
|
||||||
bookingMode: BookingMode
|
bookingMode: BookingMode
|
||||||
|
bookingModeLabel: string
|
||||||
quantity: number
|
quantity: number
|
||||||
seatCount: number
|
seatCount: number
|
||||||
|
ticketTypeId: string | null
|
||||||
ticketType: TicketType
|
ticketType: TicketType
|
||||||
|
ticketLabel: string
|
||||||
|
ticketDescription: string | null
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
totalPrice: number
|
totalPrice: number
|
||||||
status: BookingStatus
|
status: BookingStatus
|
||||||
|
statusLabel: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
confirmedAt: string | null
|
confirmedAt: string | null
|
||||||
}
|
}
|
||||||
@@ -131,32 +144,28 @@ export interface ConfirmBookingResponse {
|
|||||||
ticketReceiptWhatsApp: WhatsAppDeliveryResult
|
ticketReceiptWhatsApp: WhatsAppDeliveryResult
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBookingMode(value: string | null | undefined): value is BookingMode {
|
|
||||||
return value === 'table' || value === 'seat'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTicketType(value: string | null | undefined): value is TicketType {
|
|
||||||
return value === 'vip' || value === 'supporter'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
||||||
return value === 'pending' || value === 'confirmed'
|
return value === 'pending' || value === 'confirmed'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBookingModeLabel(value: BookingMode) {
|
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null) {
|
||||||
return value === 'table' ? `Table (${TABLE_SEAT_COUNT} seats each)` : 'Per seat'
|
if (label) {
|
||||||
}
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
export function getBookingStatusLabel(value: BookingStatus) {
|
|
||||||
return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation'
|
return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSeatCount(bookingMode: BookingMode, quantity: number) {
|
export function getSeatCount(bookingMode: Pick<BookingModeOption, 'seatsPerUnit'> | null | undefined, quantity: number) {
|
||||||
return bookingMode === 'table' ? quantity * TABLE_SEAT_COUNT : quantity
|
return quantity * (bookingMode?.seatsPerUnit ?? 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTicketCatalogItem(ticketType: TicketType) {
|
export function getTicketLabel(ticket: Pick<TicketCatalogItem, 'label'> | null | undefined, ticketType: TicketType) {
|
||||||
return BOOKING_TICKET_CATALOG.find((ticket) => ticket.value === ticketType) ?? null
|
return ticket?.label || ticketType.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBookingTicketLabel(booking: Pick<PublicBooking | ReceiptBooking, 'ticketLabel' | 'ticketType'>) {
|
||||||
|
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBookingCurrency(value: number) {
|
export function formatBookingCurrency(value: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user