Files
dticket.tootaio.com/app/pages/index.vue
xiaomai 3f7025c8e4 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
2026-05-04 10:09:08 +08:00

267 lines
8.6 KiB
Vue

<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import {
DEFAULT_PHONE_COUNTRY_CODE,
isValidPhoneNumber,
normalizePhoneNumber,
type PublicContact
} from '~~/shared/auth'
import type { BookingModeOption, CreateBookingResponse, PublicBookingConfig, TicketCatalogItem } from '~~/shared/booking'
import {
formatBookingCurrency,
getSeatCount,
type BookingMode,
type TicketType
} from '~~/shared/booking'
import { getErrorMessage } from '../utils/errors'
const toast = useToast()
const apiClient = useApiClient()
const [bookingConfig, contactsResponse] = await Promise.all([
apiClient<PublicBookingConfig>('/api/public/booking-config'),
apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
])
const eventDetails = computed(() => [
{
label: 'Date',
value: bookingConfig.event.dateLabel,
icon: 'lucide:calendar-days'
},
{
label: 'Time',
value: bookingConfig.event.timeLabel,
icon: 'lucide:clock-6'
},
{
label: 'Venue',
value: bookingConfig.event.venue,
icon: 'lucide:map-pin'
}
])
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 personInCharge = computed(() => {
return contactsResponse.contacts.map((contact) => ({
label: contact.fullName,
value: contact.id
}))
})
const form = reactive({
name: '',
phone: DEFAULT_PHONE_COUNTRY_CODE,
bookingMode: (bookingConfig.bookingModes[0]?.value ?? '') as BookingMode,
quantity: 1,
ticketType: (bookingConfig.ticketCatalog[0]?.value ?? '') as TicketType
})
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
const selectedPersonInChargeRecord = computed(() => {
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
})
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 selectedBookingMode.value?.quantityLabel || 'Quantity'
})
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
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 country code, e.g. +60123456789.' })
}
if (state.quantity < 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
}
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: normalizePhoneNumber(form.phone),
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">
{{ bookingConfig.event.title }}
</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. +60123456789" />
</UFormField>
</div>
<UFormField label="Booking Mode" name="bookingMode">
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
: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'
}" />
</UFormField>
<UFormField :label="quantityLabel" name="quantity">
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
<template #help>
This booking will generate {{ seatCount }} seat{{ seatCount === 1 ? '' : 's' }}.
</template>
</UFormField>
<UFormField label="Ticket Category" name="ticketType">
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
: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'
}" />
</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 Now" size="xl"
class="w-full justify-center" :disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
</UForm>
</UCard>
</div>
</UContainer>
</template>