feat(bookings): implement booking system and confirmation flow

Add database tables and repository for managing bookings
Create API endpoints for booking submission and capacity management
Update landing page to persist bookings before WhatsApp redirection
This commit is contained in:
2026-04-12 21:43:30 +08:00
parent 07e5d42005
commit 8541c4a2d1
17 changed files with 1585 additions and 92 deletions

View File

@@ -2,9 +2,19 @@
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
import type { CreateBookingResponse } from '~~/shared/booking'
import {
BOOKING_MODE_OPTIONS,
BOOKING_TICKET_CATALOG,
formatBookingCurrency,
getSeatCount,
getTicketCatalogItem,
type BookingMode,
type TicketType
} from '~~/shared/booking'
import { getErrorMessage } from '../utils/errors'
type BookingMode = 'table' | 'pax'
type TicketType = 'vip' | 'supporter'
const toast = useToast()
const apiClient = useApiClient()
@@ -27,32 +37,6 @@ const eventDetails = [
}
] as const
const bookingModeOptions = [
{
value: 'table',
label: 'Table (10 pax)'
},
{
value: 'pax',
label: 'Person'
}
] satisfies Array<{ value: BookingMode, label: string }>
const ticketCatalog = [
{
value: 'vip',
label: 'VIP',
description: 'RM150 / pax',
price: 150
},
{
value: 'supporter',
label: 'Supporter',
description: 'RM60 / pax',
price: 60
}
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
const personInCharge = computed(() => {
return contactsResponse.contacts.map((contact) => ({
@@ -61,13 +45,6 @@ const personInCharge = computed(() => {
}))
})
const priceFormatter = new Intl.NumberFormat('en-MY', {
style: 'currency',
currency: 'MYR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
const form = reactive({
name: '',
phone: '',
@@ -82,19 +59,16 @@ const selectedPersonInChargeRecord = computed(() => {
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
})
const selectedTicket = computed(() => {
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
})
const seatMultiplier = computed(() => form.bookingMode === 'table' ? 10 : 1)
const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? BOOKING_TICKET_CATALOG[0])
const submittingBooking = ref(false)
const quantityLabel = computed(() => {
return form.bookingMode === 'table' ? 'Number of tables' : 'Number of people'
})
const totalPrice = computed(() => form.quantity * seatMultiplier.value * selectedTicket.value.price)
const totalPrice = computed(() => getSeatCount(form.bookingMode, form.quantity) * selectedTicket.value.price)
const totalFormatted = computed(() => priceFormatter.format(totalPrice.value))
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
function validateBooking(state: typeof form): FormError[] {
const errors: FormError[] = []
@@ -116,23 +90,7 @@ function validateBooking(state: typeof form): FormError[] {
return errors
}
function buildBookingMessage() {
const bookingModeLabel = form.bookingMode === 'table' ? 'Table (10 pax each)' : 'Per person'
return [
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
'',
`Name: ${form.name.trim()}`,
`Phone Number: ${form.phone.trim()}`,
`Booking Mode: ${bookingModeLabel}`,
`Quantity: ${form.quantity}`,
`Ticket Category: ${selectedTicket.value.label}`,
`Seats Covered: ${form.quantity * seatMultiplier.value}`,
`Total Price: ${totalFormatted.value}`
].join('\n')
}
function bookTicket(event: FormSubmitEvent<typeof form>) {
async function bookTicket(event: FormSubmitEvent<typeof form>) {
const selectedPic = selectedPersonInChargeRecord.value
if (!selectedPic) {
@@ -145,28 +103,46 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
return
}
const encodedMessage = encodeURIComponent(buildBookingMessage())
const whatsappUrl = `https://wa.me/${selectedPic.phoneNumber}?text=${encodedMessage}`
const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer')
event.preventDefault()
submittingBooking.value = true
try {
const response = await apiClient<CreateBookingResponse>('/api/public/bookings', {
method: 'POST',
body: {
customerName: form.name.trim(),
customerPhone: form.phone.trim(),
bookingMode: form.bookingMode,
quantity: form.quantity,
ticketType: form.ticketType,
personInChargeId: selectedPic.id
}
})
const bookingWindow = window.open(response.whatsappUrl, '_blank', 'noopener,noreferrer')
if (!bookingWindow) {
window.location.assign(response.whatsappUrl)
return
}
if (!bookingWindow) {
toast.add({
title: 'WhatsApp could not be opened',
description: 'Allow pop-ups for this site, then submit the booking again.',
title: 'WhatsApp booking draft opened',
description: `Booking details and the confirmation link were sent to ${selectedPic.fullName}.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {
toast.add({
title: 'Booking could not be created',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
return
} finally {
submittingBooking.value = false
}
toast.add({
title: 'WhatsApp booking draft opened',
description: `Your reservation details were sent to ${selectedPic.fullName}.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
event.preventDefault()
}
</script>
@@ -206,7 +182,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField label="Booking Mode" name="bookingMode">
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
:items="bookingModeOptions" :ui="{
:items="BOOKING_MODE_OPTIONS" :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'
}" />
@@ -218,7 +194,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField label="Ticket Category" name="ticketType">
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
:items="ticketCatalog" :ui="{
:items="BOOKING_TICKET_CATALOG" :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'
}" />
@@ -242,7 +218,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
</UFormField>
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
class="w-full justify-center" :disabled="!selectedPersonInCharge" />
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
</UForm>
</UCard>
</div>