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:
@@ -326,6 +326,12 @@ const mobileMenuOpen = ref(false)
|
||||
await auth.fetchSession()
|
||||
|
||||
const allSystemMenuItems: SystemMenuItem[] = [
|
||||
{
|
||||
label: 'Bookings',
|
||||
to: '/bookings',
|
||||
icon: 'i-lucide-clipboard-list',
|
||||
matches: (path) => path.startsWith('/bookings')
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
to: '/security',
|
||||
|
||||
514
app/pages/bookings/index.vue
Normal file
514
app/pages/bookings/index.vue
Normal 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>
|
||||
209
app/pages/confirmation/[token].vue
Normal file
209
app/pages/confirmation/[token].vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
21
server/api/bookings.get.ts
Normal file
21
server/api/bookings.get.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { requireAuth } from '../utils/auth'
|
||||
import { getBookingCapacitySettings, getBookingInventorySummary, listBookings } from '../utils/booking-repository'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await requireAuth(event)
|
||||
const [bookings, settings, summary] = await Promise.all([
|
||||
listBookings(
|
||||
auth.user.role === 'super_admin'
|
||||
? undefined
|
||||
: { personInChargeId: auth.user.id }
|
||||
),
|
||||
getBookingCapacitySettings(),
|
||||
getBookingInventorySummary()
|
||||
])
|
||||
|
||||
return {
|
||||
bookings,
|
||||
settings,
|
||||
summary
|
||||
}
|
||||
})
|
||||
26
server/api/bookings/capacity.patch.ts
Normal file
26
server/api/bookings/capacity.patch.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { requireRole } from '../../utils/auth'
|
||||
import { parseBookingCapacityInput } from '../../utils/bookings'
|
||||
import { getBookingInventorySummary, updateBookingCapacitySettings } from '../../utils/booking-repository'
|
||||
import { assertBadRequest } from '../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireRole(event, 'super_admin')
|
||||
|
||||
const body = await readBody<{
|
||||
totalTables?: number | string | null
|
||||
}>(event)
|
||||
|
||||
const input = parseBookingCapacityInput(body)
|
||||
const summary = await getBookingInventorySummary()
|
||||
|
||||
assertBadRequest(
|
||||
input.totalTables === null || (input.totalTables * 10) >= summary.soldCapacitySeats,
|
||||
`Total tables cannot be lower than the currently sold capacity of ${summary.soldTables} tables and ${summary.soldSeats} seats`
|
||||
)
|
||||
|
||||
const settings = await updateBookingCapacitySettings(input)
|
||||
|
||||
return {
|
||||
settings
|
||||
}
|
||||
})
|
||||
56
server/api/public/bookings.post.ts
Normal file
56
server/api/public/bookings.post.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking'
|
||||
|
||||
import { getTicketCatalogItem, getSeatCount } from '~~/shared/booking'
|
||||
|
||||
import { buildAppUrl } from '../../utils/app-url'
|
||||
import { createBooking } from '../../utils/booking-repository'
|
||||
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
|
||||
import { assertBadRequest } from '../../utils/http'
|
||||
import { getPublicContactById } from '../../utils/user-repository'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CreateBookingResponse> => {
|
||||
const body = await readBody<{
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
bookingMode?: BookingMode
|
||||
quantity?: number
|
||||
ticketType?: TicketType
|
||||
personInChargeId?: string
|
||||
}>(event)
|
||||
|
||||
const input = parseCreateBookingInput(body)
|
||||
const personInCharge = await getPublicContactById(input.personInChargeId)
|
||||
|
||||
assertBadRequest(personInCharge, 'Selected person in charge is not available')
|
||||
|
||||
const ticket = getTicketCatalogItem(input.ticketType)
|
||||
|
||||
assertBadRequest(ticket, 'Ticket category is invalid')
|
||||
|
||||
const seatCount = getSeatCount(input.bookingMode, input.quantity)
|
||||
const totalPrice = seatCount * ticket.price
|
||||
|
||||
const { booking, confirmationToken } = await createBooking({
|
||||
customerName: input.customerName,
|
||||
customerPhone: input.customerPhone,
|
||||
bookingMode: input.bookingMode,
|
||||
quantity: input.quantity,
|
||||
seatCount,
|
||||
ticketType: input.ticketType,
|
||||
unitPrice: ticket.price,
|
||||
totalPrice,
|
||||
personInChargeId: personInCharge.id,
|
||||
personInChargeName: personInCharge.fullName,
|
||||
personInChargePhoneNumber: personInCharge.phoneNumber
|
||||
})
|
||||
|
||||
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
|
||||
const whatsappMessage = buildBookingMessage(booking, confirmationUrl)
|
||||
const whatsappUrl = `https://wa.me/${booking.personInChargePhoneNumber}?text=${encodeURIComponent(whatsappMessage)}`
|
||||
|
||||
return {
|
||||
booking,
|
||||
confirmationUrl,
|
||||
whatsappUrl
|
||||
}
|
||||
})
|
||||
15
server/api/public/bookings/[token].get.ts
Normal file
15
server/api/public/bookings/[token].get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||
import { getBookingByConfirmationToken } from '../../../utils/booking-repository'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||
const booking = await getBookingByConfirmationToken(token)
|
||||
|
||||
if (!booking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
return {
|
||||
booking
|
||||
}
|
||||
})
|
||||
35
server/api/public/bookings/[token]/confirm.post.ts
Normal file
35
server/api/public/bookings/[token]/confirm.post.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository'
|
||||
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||
const existingBooking = await getBookingByConfirmationToken(token)
|
||||
|
||||
if (!existingBooking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
if (existingBooking.status === 'confirmed') {
|
||||
return {
|
||||
booking: existingBooking,
|
||||
alreadyConfirmed: true
|
||||
}
|
||||
}
|
||||
|
||||
const summary = await getBookingInventorySummary()
|
||||
|
||||
if (summary.leftCapacitySeats !== null && existingBooking.seatCount > summary.leftCapacitySeats) {
|
||||
httpError(409, 'Not enough capacity left to confirm this booking')
|
||||
}
|
||||
|
||||
const booking = await confirmBookingByConfirmationToken(token)
|
||||
|
||||
if (!booking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
return {
|
||||
booking,
|
||||
alreadyConfirmed: false
|
||||
}
|
||||
})
|
||||
18
server/utils/app-url.ts
Normal file
18
server/utils/app-url.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
import { getRequestURL } from 'h3'
|
||||
|
||||
export function getAppOrigin(event: H3Event) {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (config.public.appUrl) {
|
||||
return new URL(config.public.appUrl).origin
|
||||
}
|
||||
|
||||
const url = getRequestURL(event)
|
||||
return `${url.protocol}//${url.host}`
|
||||
}
|
||||
|
||||
export function buildAppUrl(event: H3Event, path: string) {
|
||||
return new URL(path, getAppOrigin(event)).toString()
|
||||
}
|
||||
322
server/utils/booking-repository.ts
Normal file
322
server/utils/booking-repository.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type {
|
||||
BookingCapacitySettings,
|
||||
BookingInventorySummary,
|
||||
BookingMode,
|
||||
BookingStatus,
|
||||
PublicBooking,
|
||||
TicketType
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { calculateBookingInventorySummary, isBookingStatus } from '~~/shared/booking'
|
||||
|
||||
import { randomToken, toIsoString } from './base64url'
|
||||
import { ensureDatabaseReady } from './db-init'
|
||||
import { getSqlClient } from './postgres'
|
||||
|
||||
type DbBookingRow = {
|
||||
id: string
|
||||
confirmation_token: string
|
||||
customer_name: string
|
||||
customer_phone: string
|
||||
booking_mode: BookingMode
|
||||
quantity: number | string
|
||||
seat_count: number | string
|
||||
ticket_type: TicketType
|
||||
unit_price: number | string
|
||||
total_price: number | string
|
||||
person_in_charge_id: string
|
||||
person_in_charge_name: string
|
||||
person_in_charge_phone_number: string
|
||||
status: BookingStatus | string
|
||||
created_at: Date | string
|
||||
confirmed_at: Date | string | null
|
||||
}
|
||||
|
||||
type DbBookingSettingsRow = {
|
||||
total_tables: number | string | null
|
||||
updated_at: Date | string
|
||||
}
|
||||
|
||||
function parseInteger(value: number | string) {
|
||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||
}
|
||||
|
||||
function mapBooking(row: DbBookingRow): PublicBooking {
|
||||
return {
|
||||
id: row.id,
|
||||
confirmationToken: row.confirmation_token,
|
||||
customerName: row.customer_name,
|
||||
customerPhone: row.customer_phone,
|
||||
bookingMode: row.booking_mode,
|
||||
quantity: parseInteger(row.quantity),
|
||||
seatCount: parseInteger(row.seat_count),
|
||||
ticketType: row.ticket_type,
|
||||
unitPrice: parseInteger(row.unit_price),
|
||||
totalPrice: parseInteger(row.total_price),
|
||||
personInChargeId: row.person_in_charge_id,
|
||||
personInChargeName: row.person_in_charge_name,
|
||||
personInChargePhoneNumber: row.person_in_charge_phone_number,
|
||||
status: isBookingStatus(row.status) ? row.status : 'pending',
|
||||
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||
confirmedAt: toIsoString(row.confirmed_at)
|
||||
}
|
||||
}
|
||||
|
||||
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
|
||||
if (!row) {
|
||||
return {
|
||||
totalTables: null,
|
||||
totalSeats: null,
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalTables: row.total_tables === null ? null : parseInteger(row.total_tables),
|
||||
updatedAt: toIsoString(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBooking(input: {
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
bookingMode: BookingMode
|
||||
quantity: number
|
||||
seatCount: number
|
||||
ticketType: TicketType
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
personInChargeId: string
|
||||
personInChargeName: string
|
||||
personInChargePhoneNumber: string
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
const confirmationToken = randomToken(24)
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
insert into bookings (
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status
|
||||
)
|
||||
values (
|
||||
${randomUUID()},
|
||||
${confirmationToken},
|
||||
${input.customerName},
|
||||
${input.customerPhone},
|
||||
${input.bookingMode},
|
||||
${input.quantity},
|
||||
${input.seatCount},
|
||||
${input.ticketType},
|
||||
${input.unitPrice},
|
||||
${input.totalPrice},
|
||||
${input.personInChargeId},
|
||||
${input.personInChargeName},
|
||||
${input.personInChargePhoneNumber},
|
||||
'pending'
|
||||
)
|
||||
returning
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
`
|
||||
|
||||
return {
|
||||
booking: mapBooking(row),
|
||||
confirmationToken
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
from bookings
|
||||
where confirmation_token = ${confirmationToken}
|
||||
limit 1
|
||||
`
|
||||
|
||||
return row ? mapBooking(row) : null
|
||||
}
|
||||
|
||||
export async function listBookings(options?: {
|
||||
personInChargeId?: string
|
||||
}): Promise<PublicBooking[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = options?.personInChargeId
|
||||
? await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
from bookings
|
||||
where person_in_charge_id = ${options.personInChargeId}
|
||||
order by created_at desc
|
||||
`
|
||||
: await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
from bookings
|
||||
order by created_at desc
|
||||
`
|
||||
|
||||
return rows.map(mapBooking)
|
||||
}
|
||||
|
||||
export async function getBookingCapacitySettings(): Promise<BookingCapacitySettings> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||
select
|
||||
total_tables,
|
||||
updated_at
|
||||
from booking_settings
|
||||
where id = 'default'
|
||||
limit 1
|
||||
`
|
||||
|
||||
return mapBookingCapacitySettings(row)
|
||||
}
|
||||
|
||||
export async function updateBookingCapacitySettings(input: {
|
||||
totalTables: number | null
|
||||
}): Promise<BookingCapacitySettings> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||
update booking_settings
|
||||
set
|
||||
total_tables = ${input.totalTables},
|
||||
updated_at = now()
|
||||
where id = 'default'
|
||||
returning
|
||||
total_tables,
|
||||
updated_at
|
||||
`
|
||||
|
||||
return mapBookingCapacitySettings(row)
|
||||
}
|
||||
|
||||
export async function getBookingInventorySummary(): Promise<BookingInventorySummary> {
|
||||
const [bookings, settings] = await Promise.all([
|
||||
listBookings(),
|
||||
getBookingCapacitySettings()
|
||||
])
|
||||
|
||||
return calculateBookingInventorySummary(bookings, settings)
|
||||
}
|
||||
|
||||
export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
update bookings
|
||||
set
|
||||
status = 'confirmed',
|
||||
confirmed_at = now(),
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and status = 'pending'
|
||||
returning
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
`
|
||||
|
||||
if (row) {
|
||||
return mapBooking(row)
|
||||
}
|
||||
|
||||
return await getBookingByConfirmationToken(confirmationToken)
|
||||
}
|
||||
90
server/utils/bookings.ts
Normal file
90
server/utils/bookings.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
getBookingModeLabel,
|
||||
getTicketCatalogItem,
|
||||
isBookingMode,
|
||||
isTicketType
|
||||
} from '~~/shared/booking'
|
||||
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
||||
|
||||
import { assertBadRequest } from './http'
|
||||
|
||||
export function parseCreateBookingInput(body: {
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
bookingMode?: BookingMode
|
||||
quantity?: number
|
||||
ticketType?: TicketType
|
||||
personInChargeId?: string
|
||||
}) {
|
||||
const customerName = normalizeFullName(body.customerName || '')
|
||||
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
||||
const bookingMode = body.bookingMode
|
||||
const ticketType = body.ticketType
|
||||
const quantity = Number(body.quantity)
|
||||
const personInChargeId = (body.personInChargeId || '').trim()
|
||||
|
||||
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
|
||||
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must contain 8 to 15 digits')
|
||||
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
|
||||
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
|
||||
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
|
||||
assertBadRequest(personInChargeId, 'Person in charge is required')
|
||||
|
||||
return {
|
||||
customerName,
|
||||
customerPhone,
|
||||
bookingMode,
|
||||
quantity,
|
||||
ticketType,
|
||||
personInChargeId
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
||||
const ticket = getTicketCatalogItem(booking.ticketType)
|
||||
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
|
||||
|
||||
return [
|
||||
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
|
||||
'',
|
||||
`Name: ${booking.customerName}`,
|
||||
`Phone Number: ${booking.customerPhone}`,
|
||||
`Booking Mode: ${getBookingModeLabel(booking.bookingMode)}`,
|
||||
`Quantity: ${booking.quantity}`,
|
||||
`Ticket Category: ${ticketLabel}`,
|
||||
`Seats Covered: ${booking.seatCount}`,
|
||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||
'',
|
||||
'PIC confirmation link:',
|
||||
confirmationUrl
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function parseBookingCapacityInput(body: {
|
||||
totalTables?: number | string | null
|
||||
}): Pick<BookingCapacitySettings, 'totalTables'> {
|
||||
const totalTables = parseOptionalInteger(body.totalTables)
|
||||
|
||||
assertBadRequest(totalTables === null || totalTables >= 0, 'Total tables must be 0 or greater')
|
||||
|
||||
return {
|
||||
totalTables
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalInteger(value: number | string | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = typeof value === 'number'
|
||||
? value
|
||||
: Number.parseInt(String(value), 10)
|
||||
|
||||
assertBadRequest(Number.isInteger(parsed), 'Capacity values must be whole numbers')
|
||||
|
||||
return parsed
|
||||
}
|
||||
@@ -61,6 +61,43 @@ async function initializeDatabase() {
|
||||
on user_passkeys (user_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists bookings (
|
||||
id text primary key,
|
||||
confirmation_token text not null unique,
|
||||
customer_name text not null,
|
||||
customer_phone text not null,
|
||||
booking_mode text not null check (booking_mode in ('table', 'pax')),
|
||||
quantity integer not null check (quantity >= 1),
|
||||
seat_count integer not null check (seat_count >= 1),
|
||||
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
|
||||
unit_price integer not null check (unit_price >= 0),
|
||||
total_price integer not null check (total_price >= 0),
|
||||
person_in_charge_id text not null references users(id) on delete restrict,
|
||||
person_in_charge_name text not null,
|
||||
person_in_charge_phone_number text not null,
|
||||
status text not null check (status in ('pending', 'confirmed')) default 'pending',
|
||||
confirmed_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_settings (
|
||||
id text primary key,
|
||||
total_tables integer,
|
||||
total_seats integer,
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
insert into booking_settings (id)
|
||||
values ('default')
|
||||
on conflict (id) do nothing
|
||||
`
|
||||
|
||||
const [existingSuperAdmin] = await sql<{ id: string }[]>`
|
||||
select id
|
||||
from users
|
||||
|
||||
@@ -256,6 +256,36 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getPublicContactById(contactId: string): Promise<PublicContact | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
||||
select
|
||||
users.id,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role
|
||||
from users
|
||||
where users.id = ${contactId}
|
||||
and users.is_active = true
|
||||
and users.phone_number is not null
|
||||
and users.phone_number <> ''
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number || '',
|
||||
role: row.role
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(input: {
|
||||
username: string
|
||||
fullName: string
|
||||
|
||||
@@ -2,25 +2,13 @@ import type { H3Event } from 'h3'
|
||||
|
||||
import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server'
|
||||
|
||||
import { getRequestURL } from 'h3'
|
||||
|
||||
import { decodeBase64Url, randomToken } from './base64url'
|
||||
import { getAppOrigin } from './app-url'
|
||||
import { getRedisClient } from './redis'
|
||||
import type { PasskeyRecord } from './user-repository'
|
||||
|
||||
const CHALLENGE_TTL_SECONDS = 60 * 5
|
||||
|
||||
function getAppOrigin(event: H3Event) {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (config.public.appUrl) {
|
||||
return new URL(config.public.appUrl).origin
|
||||
}
|
||||
|
||||
const url = getRequestURL(event)
|
||||
return `${url.protocol}//${url.host}`
|
||||
}
|
||||
|
||||
export function getWebAuthnConfig(event: H3Event) {
|
||||
const config = useRuntimeConfig()
|
||||
const origin = getAppOrigin(event)
|
||||
|
||||
@@ -90,5 +90,5 @@ export function getDefaultAuthenticatedPath(
|
||||
return '/security'
|
||||
}
|
||||
|
||||
return user.role === 'super_admin' ? '/management/users' : '/security'
|
||||
return user.role === 'super_admin' ? '/management/users' : '/bookings'
|
||||
}
|
||||
|
||||
150
shared/booking.ts
Normal file
150
shared/booking.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export type BookingMode = 'table' | 'pax'
|
||||
export type TicketType = 'vip' | 'supporter'
|
||||
export type BookingStatus = 'pending' | 'confirmed'
|
||||
|
||||
export const BOOKING_MODE_OPTIONS = [
|
||||
{
|
||||
value: 'table',
|
||||
label: 'Table (10 pax)'
|
||||
},
|
||||
{
|
||||
value: 'pax',
|
||||
label: 'Person'
|
||||
}
|
||||
] satisfies Array<{ value: BookingMode, label: string }>
|
||||
|
||||
export const BOOKING_TICKET_CATALOG = [
|
||||
{
|
||||
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 }>
|
||||
|
||||
export interface PublicBooking {
|
||||
id: string
|
||||
confirmationToken: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
bookingMode: BookingMode
|
||||
quantity: number
|
||||
seatCount: number
|
||||
ticketType: TicketType
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
personInChargeId: string
|
||||
personInChargeName: string
|
||||
personInChargePhoneNumber: string
|
||||
status: BookingStatus
|
||||
createdAt: string
|
||||
confirmedAt: string | null
|
||||
}
|
||||
|
||||
export interface BookingCapacitySettings {
|
||||
totalTables: number | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface BookingInventorySummary {
|
||||
totalTables: number | null
|
||||
totalCapacitySeats: number | null
|
||||
soldTables: number
|
||||
pendingTables: number
|
||||
soldSeats: number
|
||||
pendingSeats: number
|
||||
soldCapacitySeats: number
|
||||
pendingCapacitySeats: number
|
||||
leftTables: number | null
|
||||
leftSeats: number | null
|
||||
leftCapacitySeats: number | null
|
||||
}
|
||||
|
||||
export interface CreateBookingResponse {
|
||||
booking: PublicBooking
|
||||
confirmationUrl: string
|
||||
whatsappUrl: string
|
||||
}
|
||||
|
||||
export function isBookingMode(value: string | null | undefined): value is BookingMode {
|
||||
return value === 'table' || value === 'pax'
|
||||
}
|
||||
|
||||
export function isTicketType(value: string | null | undefined): value is TicketType {
|
||||
return value === 'vip' || value === 'supporter'
|
||||
}
|
||||
|
||||
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
||||
return value === 'pending' || value === 'confirmed'
|
||||
}
|
||||
|
||||
export function getBookingModeLabel(value: BookingMode) {
|
||||
return value === 'table' ? 'Table (10 pax each)' : 'Per person'
|
||||
}
|
||||
|
||||
export function getBookingStatusLabel(value: BookingStatus) {
|
||||
return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation'
|
||||
}
|
||||
|
||||
export function getSeatCount(bookingMode: BookingMode, quantity: number) {
|
||||
return bookingMode === 'table' ? quantity * 10 : quantity
|
||||
}
|
||||
|
||||
export function getTicketCatalogItem(ticketType: TicketType) {
|
||||
return BOOKING_TICKET_CATALOG.find((ticket) => ticket.value === ticketType) ?? null
|
||||
}
|
||||
|
||||
export function formatBookingCurrency(value: number) {
|
||||
return new Intl.NumberFormat('en-MY', {
|
||||
style: 'currency',
|
||||
currency: 'MYR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function calculateBookingInventorySummary(
|
||||
bookings: Pick<PublicBooking, 'bookingMode' | 'quantity' | 'seatCount' | 'status'>[],
|
||||
settings: BookingCapacitySettings
|
||||
): BookingInventorySummary {
|
||||
const soldTables = bookings
|
||||
.filter((booking) => booking.status === 'confirmed' && booking.bookingMode === 'table')
|
||||
.reduce((total, booking) => total + booking.quantity, 0)
|
||||
|
||||
const pendingTables = bookings
|
||||
.filter((booking) => booking.status === 'pending' && booking.bookingMode === 'table')
|
||||
.reduce((total, booking) => total + booking.quantity, 0)
|
||||
|
||||
const soldSeats = bookings
|
||||
.filter((booking) => booking.status === 'confirmed' && booking.bookingMode === 'pax')
|
||||
.reduce((total, booking) => total + booking.seatCount, 0)
|
||||
|
||||
const pendingSeats = bookings
|
||||
.filter((booking) => booking.status === 'pending' && booking.bookingMode === 'pax')
|
||||
.reduce((total, booking) => total + booking.seatCount, 0)
|
||||
|
||||
const totalCapacitySeats = settings.totalTables === null ? null : settings.totalTables * 10
|
||||
const soldCapacitySeats = (soldTables * 10) + soldSeats
|
||||
const pendingCapacitySeats = (pendingTables * 10) + pendingSeats
|
||||
const leftCapacitySeats = totalCapacitySeats === null ? null : Math.max(totalCapacitySeats - soldCapacitySeats, 0)
|
||||
|
||||
return {
|
||||
totalTables: settings.totalTables,
|
||||
totalCapacitySeats,
|
||||
soldTables,
|
||||
pendingTables,
|
||||
soldSeats,
|
||||
pendingSeats,
|
||||
soldCapacitySeats,
|
||||
pendingCapacitySeats,
|
||||
leftTables: leftCapacitySeats === null ? null : Math.floor(leftCapacitySeats / 10),
|
||||
leftSeats: leftCapacitySeats === null ? null : leftCapacitySeats % 10,
|
||||
leftCapacitySeats
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user