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
This commit is contained in:
@@ -213,7 +213,7 @@
|
||||
<template #quantity-cell="{ row }">
|
||||
<div class="space-y-0.5 py-0.5">
|
||||
<div class="text-sm font-medium text-default">
|
||||
{{ ticketLabel(row.original.ticketType) }}
|
||||
{{ ticketLabel(row.original) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -243,7 +243,7 @@
|
||||
<template #status-cell="{ row }">
|
||||
<div class="space-y-1 py-0.5">
|
||||
<UBadge
|
||||
:label="getBookingStatusLabel(row.original.status)"
|
||||
:label="getBookingStatusLabel(row.original.status, row.original.statusLabel)"
|
||||
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
@@ -288,12 +288,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, TicketType } from '~~/shared/booking'
|
||||
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
getBookingStatusLabel,
|
||||
getTicketCatalogItem
|
||||
getBookingStatusLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
@@ -370,6 +369,7 @@ const filteredBookings = computed(() => {
|
||||
booking.personInChargeName,
|
||||
booking.personInChargePhoneNumber,
|
||||
booking.ticketType,
|
||||
booking.ticketLabel,
|
||||
booking.status
|
||||
].some((value) => value.toLowerCase().includes(keyword))
|
||||
})
|
||||
@@ -385,8 +385,8 @@ const confirmedCount = computed(() => {
|
||||
|
||||
await refreshBookings()
|
||||
|
||||
function ticketLabel(ticketType: TicketType) {
|
||||
return getTicketCatalogItem(ticketType)?.label || ticketType.toUpperCase()
|
||||
function ticketLabel(booking: PublicBooking) {
|
||||
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
||||
}
|
||||
|
||||
function confirmationPath(booking: PublicBooking) {
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
getBookingStatusLabel,
|
||||
getTicketCatalogItem
|
||||
getBookingStatusLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
@@ -32,7 +31,7 @@ try {
|
||||
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 ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
|
||||
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
|
||||
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
|
||||
const detailRows = computed(() => {
|
||||
@@ -139,7 +138,7 @@ async function confirmBooking() {
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-muted">
|
||||
Booking status
|
||||
</p>
|
||||
<UBadge :label="getBookingStatusLabel(booking.status)" :color="statusColor" variant="soft" />
|
||||
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel)" :color="statusColor" variant="soft" />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted">
|
||||
|
||||
@@ -7,17 +7,10 @@ import {
|
||||
normalizePhoneNumber,
|
||||
type PublicContact
|
||||
} from '~~/shared/auth'
|
||||
import type { CreateBookingResponse } from '~~/shared/booking'
|
||||
import type { BookingModeOption, CreateBookingResponse, PublicBookingConfig, TicketCatalogItem } from '~~/shared/booking'
|
||||
import {
|
||||
BOOKING_MODE_OPTIONS,
|
||||
BOOKING_TICKET_CATALOG,
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getSeatCount,
|
||||
getTicketCatalogItem,
|
||||
type BookingMode,
|
||||
type TicketType
|
||||
} from '~~/shared/booking'
|
||||
@@ -28,25 +21,44 @@ import { getErrorMessage } from '../utils/errors'
|
||||
const toast = useToast()
|
||||
const apiClient = useApiClient()
|
||||
|
||||
const eventDetails = [
|
||||
const [bookingConfig, contactsResponse] = await Promise.all([
|
||||
apiClient<PublicBookingConfig>('/api/public/booking-config'),
|
||||
apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||||
])
|
||||
|
||||
const eventDetails = computed(() => [
|
||||
{
|
||||
label: 'Date',
|
||||
value: DINNER_EVENT_DATE_LABEL,
|
||||
value: bookingConfig.event.dateLabel,
|
||||
icon: 'lucide:calendar-days'
|
||||
},
|
||||
{
|
||||
label: 'Time',
|
||||
value: DINNER_EVENT_TIME_LABEL,
|
||||
value: bookingConfig.event.timeLabel,
|
||||
icon: 'lucide:clock-6'
|
||||
},
|
||||
{
|
||||
label: 'Venue',
|
||||
value: DINNER_EVENT_VENUE,
|
||||
value: bookingConfig.event.venue,
|
||||
icon: 'lucide:map-pin'
|
||||
}
|
||||
] as const
|
||||
])
|
||||
|
||||
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 contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||||
const personInCharge = computed(() => {
|
||||
return contactsResponse.contacts.map((contact) => ({
|
||||
label: contact.fullName,
|
||||
@@ -57,9 +69,9 @@ const personInCharge = computed(() => {
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: DEFAULT_PHONE_COUNTRY_CODE,
|
||||
bookingMode: 'table' as BookingMode,
|
||||
bookingMode: (bookingConfig.bookingModes[0]?.value ?? '') as BookingMode,
|
||||
quantity: 1,
|
||||
ticketType: 'vip' as TicketType
|
||||
ticketType: (bookingConfig.ticketCatalog[0]?.value ?? '') as TicketType
|
||||
})
|
||||
|
||||
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
|
||||
@@ -68,15 +80,22 @@ const selectedPersonInChargeRecord = computed(() => {
|
||||
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
|
||||
})
|
||||
|
||||
const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? BOOKING_TICKET_CATALOG[0])
|
||||
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 form.bookingMode === 'table' ? 'Number of Tables' : 'Number of Seats'
|
||||
return selectedBookingMode.value?.quantityLabel || 'Quantity'
|
||||
})
|
||||
|
||||
const seatCount = computed(() => getSeatCount(form.bookingMode, form.quantity))
|
||||
const totalPrice = computed(() => seatCount.value * selectedTicket.value.price)
|
||||
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
|
||||
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
|
||||
|
||||
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
|
||||
|
||||
@@ -97,6 +116,14 @@ function validateBooking(state: typeof form): FormError[] {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -161,7 +188,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
<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">
|
||||
{{ DINNER_EVENT_TITLE }}
|
||||
{{ bookingConfig.event.title }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +219,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
<UFormField label="Booking Mode" name="bookingMode">
|
||||
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
|
||||
:items="BOOKING_MODE_OPTIONS" :ui="{
|
||||
: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'
|
||||
}" />
|
||||
@@ -207,7 +234,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
<UFormField label="Ticket Category" name="ticketType">
|
||||
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
|
||||
:items="BOOKING_TICKET_CATALOG" :ui="{
|
||||
: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'
|
||||
}" />
|
||||
@@ -231,7 +258,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
</UFormField>
|
||||
|
||||
<UButton id="getTicketBtn" type="submit" label="Book Now" size="xl"
|
||||
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
|
||||
class="w-full justify-center" :disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
import type { PublicBookingReceipt, PublicBookingSeatWithUrl } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getBookingStatusLabel,
|
||||
getSeatLabel,
|
||||
getTicketCatalogItem
|
||||
getSeatLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
@@ -51,7 +46,8 @@ try {
|
||||
|
||||
const receipt = ref(initialReceipt)
|
||||
|
||||
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
||||
const eventDetails = computed(() => receipt.value.booking.event)
|
||||
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
|
||||
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
|
||||
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
|
||||
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
|
||||
@@ -66,7 +62,7 @@ const statusRows = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Status',
|
||||
value: getBookingStatusLabel(receipt.value.booking.status),
|
||||
value: getBookingStatusLabel(receipt.value.booking.status, receipt.value.booking.statusLabel),
|
||||
isBadge: true
|
||||
},
|
||||
{
|
||||
@@ -126,15 +122,15 @@ function buildSeatBundleText(
|
||||
: null
|
||||
|
||||
return [
|
||||
DINNER_EVENT_TITLE,
|
||||
eventDetails.value.title,
|
||||
`Guest: ${receipt.value.booking.customerName}`,
|
||||
recipientLabel,
|
||||
recipientPhoneLabel,
|
||||
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
|
||||
`Category: ${ticketLabel.value}`,
|
||||
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
||||
`Time: ${DINNER_EVENT_TIME_LABEL}`,
|
||||
`Venue: ${DINNER_EVENT_VENUE}`,
|
||||
`Date: ${eventDetails.value.dateLabel}`,
|
||||
`Time: ${eventDetails.value.timeLabel}`,
|
||||
`Venue: ${eventDetails.value.venue}`,
|
||||
'',
|
||||
...seats.flatMap((seat) => [
|
||||
`${getSeatLabel(seat.seatNumber)}:`,
|
||||
@@ -222,7 +218,7 @@ async function shareSeats() {
|
||||
recipientPhone: shareForm.recipientPhone
|
||||
})
|
||||
const shared = await shareLink({
|
||||
title: `${DINNER_EVENT_TITLE} seats`,
|
||||
title: `${eventDetails.value.title} seats`,
|
||||
text: shareText,
|
||||
clipboardText: shareText,
|
||||
successTitle: 'Seats ready',
|
||||
@@ -289,7 +285,7 @@ async function shareSeat(seat: PublicBookingSeatWithUrl) {
|
||||
|
||||
try {
|
||||
const shared = await shareLink({
|
||||
title: `${DINNER_EVENT_TITLE} ${getSeatLabel(seat.seatNumber)}`,
|
||||
title: `${eventDetails.value.title} ${getSeatLabel(seat.seatNumber)}`,
|
||||
text: buildSeatBundleText([seat]),
|
||||
clipboardText: buildSeatBundleText([seat]),
|
||||
successTitle: 'Seat ready',
|
||||
@@ -365,7 +361,7 @@ async function openBatchShare() {
|
||||
<div class="space-y-1 text-center">
|
||||
<UBadge label="Ticket Receipt" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
|
||||
{{ DINNER_EVENT_TITLE }}
|
||||
{{ eventDetails.title }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
import type { PublicSeatReceipt } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getSeatLabel,
|
||||
getTicketCatalogItem
|
||||
getSeatLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { formatDateTime } from '../../utils/formatters'
|
||||
@@ -31,7 +26,8 @@ try {
|
||||
|
||||
const receipt = ref(initialReceipt)
|
||||
|
||||
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
||||
const eventDetails = computed(() => receipt.value.booking.event)
|
||||
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
|
||||
const totalFormatted = computed(() => formatBookingCurrency(receipt.value.booking.totalPrice))
|
||||
</script>
|
||||
|
||||
@@ -44,7 +40,7 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
|
||||
{{ getSeatLabel(receipt.seat.seatNumber) }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
{{ DINNER_EVENT_TITLE }}
|
||||
{{ eventDetails.title }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -124,15 +120,15 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
|
||||
<div class="space-y-3 rounded-2xl border border-default bg-elevated p-4">
|
||||
<div class="flex items-center gap-3 text-sm text-default">
|
||||
<UIcon name="i-lucide-calendar-days" class="size-4 text-muted" />
|
||||
<span>{{ DINNER_EVENT_DATE_LABEL }}</span>
|
||||
<span>{{ eventDetails.dateLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-default">
|
||||
<UIcon name="i-lucide-clock-6" class="size-4 text-muted" />
|
||||
<span>{{ DINNER_EVENT_TIME_LABEL }}</span>
|
||||
<span>{{ eventDetails.timeLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-default">
|
||||
<UIcon name="i-lucide-map-pin" class="size-4 text-muted" />
|
||||
<span>{{ DINNER_EVENT_VENUE }}</span>
|
||||
<span>{{ eventDetails.venue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user