Add PostgreSQL and Redis integration for users and sessions Implement password and WebAuthn passkey login flows Add Docker stack, super-admin seeding, and protected routes
251 lines
7.8 KiB
Vue
251 lines
7.8 KiB
Vue
<script lang="ts" setup>
|
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
|
|
|
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
|
|
|
|
type BookingMode = 'table' | 'pax'
|
|
type TicketType = 'vip' | 'supporter'
|
|
|
|
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 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) => ({
|
|
label: contact.fullName,
|
|
value: contact.id
|
|
}))
|
|
})
|
|
|
|
const priceFormatter = new Intl.NumberFormat('en-MY', {
|
|
style: 'currency',
|
|
currency: 'MYR',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0
|
|
})
|
|
|
|
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(() => {
|
|
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
|
|
})
|
|
|
|
const seatMultiplier = computed(() => form.bookingMode === 'table' ? 10 : 1)
|
|
|
|
const quantityLabel = computed(() => {
|
|
return form.bookingMode === 'table' ? 'Number of tables' : 'Number of people'
|
|
})
|
|
|
|
const totalPrice = computed(() => form.quantity * seatMultiplier.value * selectedTicket.value.price)
|
|
|
|
const totalFormatted = computed(() => priceFormatter.format(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
|
|
}
|
|
|
|
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>) {
|
|
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
|
|
}
|
|
|
|
const encodedMessage = encodeURIComponent(buildBookingMessage())
|
|
const whatsappUrl = `https://wa.me/${selectedPic.phoneNumber}?text=${encodedMessage}`
|
|
const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer')
|
|
|
|
if (!bookingWindow) {
|
|
toast.add({
|
|
title: 'WhatsApp could not be opened',
|
|
description: 'Allow pop-ups for this site, then submit the booking again.',
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
return
|
|
}
|
|
|
|
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>
|
|
|
|
<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="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" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Ticket Category" name="ticketType">
|
|
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
|
|
:items="ticketCatalog" :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" />
|
|
</UForm>
|
|
</UCard>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|