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

@@ -0,0 +1,514 @@
<template>
<UContainer class="py-6">
<div class="mx-auto max-w-7xl space-y-4">
<div class="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-1">
<UBadge label="Bookings" color="primary" variant="soft" size="sm" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted">
Booking list
</h1>
<p class="text-sm text-muted">
{{ auth.isSuperAdmin.value ? 'All submitted bookings across every PIC.' : 'Bookings assigned to you as PIC.' }}
</p>
</div>
<UBadge
:label="auth.isSuperAdmin.value ? 'Super Admin View' : 'Staff View'"
:color="auth.isSuperAdmin.value ? 'primary' : 'neutral'"
variant="soft"
size="sm"
class="rounded-full"
/>
</div>
<UAlert
title="Inventory counting rule"
:description="inventoryDescription"
color="info"
icon="i-lucide-info"
variant="soft"
:ui="{ root: 'py-3', title: 'text-sm font-medium', description: 'text-xs sm:text-sm' }"
/>
<div v-if="auth.isSuperAdmin.value" class="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_18rem]">
<UCard class="border border-default bg-default shadow-sm" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
<template #header>
<div class="space-y-1">
<h2 class="text-base font-semibold text-highlighted">
Capacity settings
</h2>
<p class="text-xs text-muted sm:text-sm">
Configure the event capacity used to calculate sold and left inventory.
</p>
</div>
</template>
<UForm :state="capacityForm" class="space-y-3" @submit="saveCapacity">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<UFormField name="totalTables" label="Total Tables" class="flex-1">
<UInput
v-model="capacityForm.totalTables"
type="number"
inputmode="numeric"
min="0"
size="md"
class="w-full"
placeholder="Leave blank for no table limit"
/>
</UFormField>
<UButton
type="submit"
label="Save Capacity"
icon="i-lucide-save"
size="md"
:loading="savingCapacity"
class="justify-center sm:min-w-32"
/>
</div>
<div class="flex items-center justify-between gap-3 border-t border-default pt-3">
<p class="text-xs text-muted sm:text-sm">
Last updated {{ settings.updatedAt ? formatDateTime(settings.updatedAt) : 'Not set yet' }}
</p>
</div>
</UForm>
</UCard>
<UCard class="border border-default bg-default shadow-sm" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
<template #header>
<div class="space-y-1">
<h2 class="text-base font-semibold text-highlighted">
Pending reservations
</h2>
<p class="text-xs text-muted sm:text-sm">
Pending bookings are not counted as sold until the PIC confirms them.
</p>
</div>
</template>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-default bg-muted/20 p-3">
<p class="text-xs uppercase tracking-wide text-muted">
Pending tables
</p>
<p class="mt-1 text-xl font-semibold text-highlighted">
{{ summary.pendingTables }}
</p>
</div>
<div class="rounded-lg border border-default bg-muted/20 p-3">
<p class="text-xs uppercase tracking-wide text-muted">
Pending seats
</p>
<p class="mt-1 text-xl font-semibold text-highlighted">
{{ summary.pendingSeats }}
</p>
</div>
</div>
</UCard>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<UCard
v-for="item in inventoryCards"
:key="item.label"
class="border border-default bg-default shadow-sm"
:ui="{ body: 'px-4 py-3' }"
>
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
{{ item.label }}
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ item.value }}
</p>
<p v-if="item.meta" class="text-xs text-muted">
{{ item.meta }}
</p>
</div>
</UCard>
</div>
<div class="grid gap-3 md:grid-cols-3">
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
Total Tables
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ formatInventoryNumber(summary.totalTables) }}
</p>
</div>
</UCard>
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
Total bookings
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ bookings.length }}
</p>
</div>
</UCard>
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
Booking status
</p>
<p class="text-sm font-medium text-default">
{{ confirmedCount }} confirmed, {{ pendingCount }} pending
</p>
</div>
</UCard>
</div>
<UCard
class="border border-default bg-default shadow-sm"
:ui="{ header: 'px-4 py-3', body: 'p-0 sm:p-0' }"
>
<template #header>
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<UInput
v-model="searchQuery"
size="md"
class="w-full sm:w-72"
placeholder="Search guest, phone, PIC, or ticket"
/>
<UButton
label="Refresh"
color="neutral"
variant="outline"
icon="i-lucide-refresh-cw"
size="md"
:loading="loadingBookings"
@click="refreshBookings"
/>
</div>
</div>
</template>
<div class="overflow-x-auto">
<UTable
:data="filteredBookings"
:columns="columns"
:loading="loadingBookings"
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
sticky="header"
caption="Bookings"
class="min-w-[980px]"
>
<template #customerName-cell="{ row }">
<div class="min-w-0 space-y-0.5 py-0.5">
<div class="text-sm font-semibold leading-tight text-highlighted">
{{ row.original.customerName }}
</div>
<div class="text-xs text-muted">
{{ row.original.customerPhone }}
</div>
</div>
</template>
<template #bookingMode-cell="{ row }">
<div class="space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ getBookingModeLabel(row.original.bookingMode) }}
</div>
<div class="text-xs text-muted">
{{ row.original.quantity }} x {{ ticketLabel(row.original.ticketType) }}
</div>
</div>
</template>
<template #seatCount-cell="{ row }">
<div class="space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ row.original.seatCount }} seats
</div>
<div class="text-xs text-muted">
{{ formatBookingCurrency(row.original.totalPrice) }}
</div>
</div>
</template>
<template #personInChargeName-cell="{ row }">
<div class="min-w-0 space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ row.original.personInChargeName }}
</div>
<div class="text-xs text-muted">
{{ row.original.personInChargePhoneNumber }}
</div>
</div>
</template>
<template #status-cell="{ row }">
<div class="space-y-1 py-0.5">
<UBadge
:label="getBookingStatusLabel(row.original.status)"
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
variant="soft"
size="sm"
/>
<div class="text-xs text-muted">
{{ row.original.status === 'confirmed' ? `Confirmed ${formatDateTime(row.original.confirmedAt)}` : 'Awaiting PIC confirmation' }}
</div>
</div>
</template>
<template #createdAt-cell="{ row }">
<span class="text-xs text-muted">
{{ formatDateTime(row.original.createdAt) }}
</span>
</template>
<template #actions-cell="{ row }">
<div class="flex flex-wrap justify-end gap-1.5 py-0.5">
<UButton
:to="confirmationPath(row.original)"
label="Open"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
size="sm"
/>
</div>
</template>
</UTable>
</div>
</UCard>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, TicketType } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingModeLabel,
getBookingStatusLabel,
getTicketCatalogItem
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
definePageMeta({
middleware: 'auth'
})
const toast = useToast()
const apiClient = useApiClient()
const auth = useAuth()
const bookings = ref<PublicBooking[]>([])
const loadingBookings = ref(false)
const savingCapacity = ref(false)
const searchQuery = ref('')
const settings = reactive<BookingCapacitySettings>({
totalTables: null,
updatedAt: null
})
const summary = reactive<BookingInventorySummary>({
totalTables: null,
totalCapacitySeats: null,
soldTables: 0,
pendingTables: 0,
soldSeats: 0,
pendingSeats: 0,
soldCapacitySeats: 0,
pendingCapacitySeats: 0,
leftTables: null,
leftSeats: null,
leftCapacitySeats: null
})
const capacityForm = reactive({
totalTables: ''
})
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'bookingMode', header: 'Booking' },
{ accessorKey: 'seatCount', header: 'Seats / Total' },
{ accessorKey: 'personInChargeName', header: 'PIC' },
{ id: 'status', header: 'Status' },
{ accessorKey: 'createdAt', header: 'Submitted' },
{ id: 'actions', header: 'Actions' }
]
const inventoryDescription = computed(() => {
return 'Set only the total number of tables. The system treats each table as 10 seats, then auto-calculates sold and remaining inventory.'
})
const inventoryCards = computed(() => {
return [
{
label: 'Tables Sold',
value: String(summary.soldTables)
},
{
label: 'Seats Sold',
value: String(summary.soldSeats)
},
{
label: 'Capacity Used',
value: String(summary.soldCapacitySeats),
meta: 'in seats'
},
{
label: 'Tables Left',
value: formatInventoryNumber(summary.leftTables)
},
{
label: 'Seats Left',
value: formatInventoryNumber(summary.leftSeats)
}
]
})
const filteredBookings = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
if (!keyword) {
return bookings.value
}
return bookings.value.filter((booking) => {
return [
booking.customerName,
booking.customerPhone,
booking.personInChargeName,
booking.personInChargePhoneNumber,
booking.ticketType,
booking.status
].some((value) => value.toLowerCase().includes(keyword))
})
})
const pendingCount = computed(() => {
return bookings.value.filter((booking) => booking.status === 'pending').length
})
const confirmedCount = computed(() => {
return bookings.value.filter((booking) => booking.status === 'confirmed').length
})
await refreshBookings()
function ticketLabel(ticketType: TicketType) {
return getTicketCatalogItem(ticketType)?.label || ticketType.toUpperCase()
}
function confirmationPath(booking: PublicBooking) {
return `/confirmation/${booking.confirmationToken}`
}
function formatInventoryNumber(value: number | null) {
return value === null ? 'Not set' : String(value)
}
function normalizeCapacityValue(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : null
}
const normalized = String(value || '').trim()
return normalized || null
}
function syncCapacityForm(nextSettings: BookingCapacitySettings) {
capacityForm.totalTables = nextSettings.totalTables === null ? '' : String(nextSettings.totalTables)
}
function applySettings(nextSettings: BookingCapacitySettings) {
settings.totalTables = nextSettings.totalTables
settings.updatedAt = nextSettings.updatedAt
syncCapacityForm(nextSettings)
}
function applySummary(nextSummary: BookingInventorySummary) {
summary.totalTables = nextSummary.totalTables
summary.totalCapacitySeats = nextSummary.totalCapacitySeats
summary.soldTables = nextSummary.soldTables
summary.pendingTables = nextSummary.pendingTables
summary.soldSeats = nextSummary.soldSeats
summary.pendingSeats = nextSummary.pendingSeats
summary.soldCapacitySeats = nextSummary.soldCapacitySeats
summary.pendingCapacitySeats = nextSummary.pendingCapacitySeats
summary.leftTables = nextSummary.leftTables
summary.leftSeats = nextSummary.leftSeats
summary.leftCapacitySeats = nextSummary.leftCapacitySeats
}
async function refreshBookings() {
if (loadingBookings.value) {
return
}
loadingBookings.value = true
try {
const response = await apiClient<{
bookings: PublicBooking[]
settings: BookingCapacitySettings
summary: BookingInventorySummary
}>('/api/bookings')
bookings.value = response.bookings
applySettings(response.settings)
applySummary(response.summary)
} catch (error: any) {
toast.add({
title: 'Unable to load bookings',
description: getErrorMessage(error, 'The booking list could not be loaded.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
loadingBookings.value = false
}
}
async function saveCapacity(event: Event) {
event.preventDefault()
if (!auth.isSuperAdmin.value || savingCapacity.value) {
return
}
savingCapacity.value = true
try {
const response = await apiClient<{ settings: BookingCapacitySettings }>('/api/bookings/capacity', {
method: 'PATCH',
body: {
totalTables: normalizeCapacityValue(capacityForm.totalTables)
}
})
applySettings(response.settings)
await refreshBookings()
toast.add({
title: 'Capacity updated',
description: 'The booking inventory limits have been saved.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Capacity update failed',
description: getErrorMessage(error, 'Unable to save the booking inventory limits.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingCapacity.value = false
}
}
</script>

View File

@@ -0,0 +1,209 @@
<script lang="ts" setup>
import type { PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingModeLabel,
getBookingStatusLabel,
getTicketCatalogItem
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
const route = useRoute()
const toast = useToast()
const apiClient = useApiClient()
const token = String(route.params.token || '')
const confirming = ref(false)
let initialBooking: PublicBooking
try {
const response = await apiClient<{ booking: PublicBooking }>(`/api/public/bookings/${token}`)
initialBooking = response.booking
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.data?.statusCode || 404,
statusMessage: error?.data?.statusMessage || error?.message || 'Booking not found'
})
}
const booking = ref(initialBooking)
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
const detailRows = computed(() => {
const rows = [
{
label: 'Guest / Organizer',
value: booking.value.customerName
},
{
label: 'Contact Number',
value: booking.value.customerPhone
},
{
label: 'Person In Charge',
value: booking.value.personInChargeName
},
{
label: 'PIC Phone',
value: booking.value.personInChargePhoneNumber
},
{
label: 'Booking Mode',
value: getBookingModeLabel(booking.value.bookingMode)
},
{
label: 'Ticket Category',
value: ticketLabel.value
},
{
label: 'Quantity',
value: String(booking.value.quantity)
},
{
label: 'Seats Covered',
value: String(booking.value.seatCount)
},
{
label: 'Submitted',
value: formatDateTime(booking.value.createdAt)
}
]
if (booking.value.confirmedAt) {
rows.push({
label: 'Confirmed At',
value: formatDateTime(booking.value.confirmedAt)
})
}
return rows
})
async function confirmBooking() {
if (booking.value.status === 'confirmed') {
return
}
confirming.value = true
try {
const response = await apiClient<{ booking: PublicBooking, alreadyConfirmed: boolean }>(
`/api/public/bookings/${token}/confirm`,
{
method: 'POST'
}
)
booking.value = response.booking
toast.add({
title: response.alreadyConfirmed ? 'Booking already confirmed' : 'Booking confirmed',
description: response.alreadyConfirmed
? 'This booking had already been confirmed earlier.'
: 'The booking details have been confirmed successfully.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {
toast.add({
title: 'Confirmation failed',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
confirming.value = false
}
}
</script>
<template>
<UContainer class="py-8">
<div class="mx-auto max-w-3xl space-y-5">
<div class="space-y-2 text-center">
<UBadge label="PIC Confirmation" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
Review Booking Details
</h1>
<p class="text-sm text-muted">
Confirm the booking after verifying the details below.
</p>
</div>
<UCard
class="border border-default bg-default shadow-sm"
:ui="{ body: 'space-y-4 p-4 sm:p-5' }"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted">
Booking status
</p>
<UBadge :label="getBookingStatusLabel(booking.status)" :color="statusColor" variant="soft" />
</div>
<div class="text-sm text-muted">
Submitted {{ formatDateTime(booking.createdAt) }}
</div>
</div>
<UAlert
v-if="booking.status === 'confirmed'"
title="Booking already confirmed"
:description="`Confirmed on ${formatDateTime(booking.confirmedAt)}.`"
color="success"
icon="i-lucide-badge-check"
/>
<div class="overflow-hidden rounded-xl border border-default">
<div
v-for="row in detailRows"
:key="row.label"
class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[11rem_minmax(0,1fr)]"
>
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
{{ row.label }}
</div>
<div class="min-w-0 font-medium text-highlighted break-words">
{{ row.value }}
</div>
</div>
<div class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 bg-primary/5 px-4 py-3 sm:grid-cols-[11rem_minmax(0,1fr)]">
<div class="text-xs font-semibold uppercase tracking-wide text-primary sm:text-sm sm:normal-case sm:tracking-normal">
Total Price
</div>
<div class="min-w-0 text-lg font-bold text-highlighted sm:text-xl">
{{ totalFormatted }}
</div>
</div>
</div>
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<UButton
to="/"
label="Back To Booking Form"
color="neutral"
variant="ghost"
class="justify-center"
/>
<UButton
label="Confirm This Booking"
icon="i-lucide-check-check"
class="justify-center"
:disabled="booking.status === 'confirmed'"
:loading="confirming"
@click="confirmBooking"
/>
</div>
</UCard>
</div>
</UContainer>
</template>

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>