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:
2026-05-04 10:09:08 +08:00
parent 06165f80db
commit 3f7025c8e4
13 changed files with 970 additions and 342 deletions

View File

@@ -7,17 +7,10 @@ import {
normalizePhoneNumber,
type PublicContact
} from '~~/shared/auth'
import type { CreateBookingResponse } from '~~/shared/booking'
import type { BookingModeOption, CreateBookingResponse, PublicBookingConfig, TicketCatalogItem } from '~~/shared/booking'
import {
BOOKING_MODE_OPTIONS,
BOOKING_TICKET_CATALOG,
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getSeatCount,
getTicketCatalogItem,
type BookingMode,
type TicketType
} from '~~/shared/booking'
@@ -28,25 +21,44 @@ import { getErrorMessage } from '../utils/errors'
const toast = useToast()
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',
value: DINNER_EVENT_DATE_LABEL,
value: bookingConfig.event.dateLabel,
icon: 'lucide:calendar-days'
},
{
label: 'Time',
value: DINNER_EVENT_TIME_LABEL,
value: bookingConfig.event.timeLabel,
icon: 'lucide:clock-6'
},
{
label: 'Venue',
value: DINNER_EVENT_VENUE,
value: bookingConfig.event.venue,
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(() => {
return contactsResponse.contacts.map((contact) => ({
label: contact.fullName,
@@ -57,9 +69,9 @@ const personInCharge = computed(() => {
const form = reactive({
name: '',
phone: DEFAULT_PHONE_COUNTRY_CODE,
bookingMode: 'table' as BookingMode,
bookingMode: (bookingConfig.bookingModes[0]?.value ?? '') as BookingMode,
quantity: 1,
ticketType: 'vip' as TicketType
ticketType: (bookingConfig.ticketCatalog[0]?.value ?? '') as TicketType
})
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
@@ -68,15 +80,22 @@ const selectedPersonInChargeRecord = computed(() => {
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 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 totalPrice = computed(() => seatCount.value * selectedTicket.value.price)
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
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.` })
}
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
}
@@ -161,7 +188,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<div class="mx-auto max-w-2xl">
<div class="mb-8 text-center">
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-4xl">
{{ DINNER_EVENT_TITLE }}
{{ bookingConfig.event.title }}
</h1>
</div>
@@ -192,7 +219,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField label="Booking Mode" name="bookingMode">
<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',
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">
<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',
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>
<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>
</UCard>
</div>