Files
dticket.tootaio.com/app/pages/index.vue
xiaomai 8541c4a2d1 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
2026-04-12 21:43:30 +08:00

227 lines
7.2 KiB
Vue

<script lang="ts" setup>
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'
const toast = useToast()
const apiClient = useApiClient()
const eventDetails = [
{
label: 'Date',
value: 'Saturday, 30 May 2026',
icon: 'lucide:calendar-days'
},
{
label: 'Time',
value: '6:30 PM',
icon: 'lucide:clock-6'
},
{
label: 'Venue',
value: "Yong Peng's Chee Ann Kor",
icon: 'lucide:map-pin'
}
] as const
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
const personInCharge = computed(() => {
return contactsResponse.contacts.map((contact) => ({
label: contact.fullName,
value: contact.id
}))
})
const form = reactive({
name: '',
phone: '',
bookingMode: 'table' as BookingMode,
quantity: 1,
ticketType: 'vip' as TicketType
})
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
const selectedPersonInChargeRecord = computed(() => {
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
})
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(() => getSeatCount(form.bookingMode, form.quantity) * selectedTicket.value.price)
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
function validateBooking(state: typeof form): FormError[] {
const errors: FormError[] = []
if (!state.name.trim()) {
errors.push({ name: 'name', message: 'Please enter the guest or organizer name.' })
}
if (!state.phone.trim()) {
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
} else if (!isValidPhoneNumber(state.phone.trim())) {
errors.push({ name: 'phone', message: 'Use a valid phone number with 8 to 15 digits.' })
}
if (state.quantity < 1) {
errors.push({ name: 'quantity', message: 'Quantity must be at least 1.' })
}
return errors
}
async function bookTicket(event: FormSubmitEvent<typeof form>) {
const selectedPic = selectedPersonInChargeRecord.value
if (!selectedPic) {
toast.add({
title: 'No person in charge available',
description: 'Add a user with a phone number in the management page first.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
return
}
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
}
toast.add({
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'
})
} finally {
submittingBooking.value = false
}
}
</script>
<template>
<UContainer class="py-8">
<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">
DAP JOHOR 60th Anniversary Celebration
</h1>
</div>
<UCard id="booking-form" class="border border-default bg-default" :ui="{
header: 'space-y-4',
body: 'space-y-6'
}">
<template #header>
<div class="space-y-3">
<div v-for="detail in eventDetails" :key="detail.label"
class="flex items-center gap-3 text-sm text-default">
<UIcon :name="detail.icon" class="size-4 text-muted" />
<span>{{ detail.value }}</span>
</div>
</div>
</template>
<UForm :state="form" :validate="validateBooking" class="space-y-6" @submit="bookTicket">
<div class="space-y-5">
<UFormField name="name" label="Name" required>
<UInput v-model="form.name" size="xl" class="w-full" placeholder="e.g. John Doe" />
</UFormField>
<UFormField name="phone" label="Phone Number" required>
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. 0123456789" />
</UFormField>
</div>
<UFormField label="Booking Mode" name="bookingMode">
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
: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'
}" />
</UFormField>
<UFormField :label="quantityLabel" name="quantity">
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
</UFormField>
<UFormField label="Ticket Category" name="ticketType">
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
: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'
}" />
</UFormField>
<div class="rounded-xl border border-default bg-muted px-4 py-4">
<div class="flex items-center justify-between gap-4">
<span class="text-sm font-medium text-muted">Total Price</span>
<span class="text-2xl font-bold text-highlighted">{{ totalFormatted }}</span>
</div>
</div>
<UFormField label="Person In Charge">
<USelect
v-model="selectedPersonInCharge"
size="xl"
class="w-full"
:items="personInCharge"
:disabled="!personInCharge.length"
/>
</UFormField>
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
</UForm>
</UCard>
</div>
</UContainer>
</template>