Implement useLocale composable and shared translation dictionaries Translate public pages, booking flow, and receipt views Store booking locale to send localized WhatsApp notifications
317 lines
10 KiB
Vue
317 lines
10 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 { locale, t } = useLocale()
|
||
|
||
const [bookingConfig, contactsResponse] = await Promise.all([
|
||
apiClient<PublicBookingConfig>('/api/public/booking-config'),
|
||
apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||
])
|
||
|
||
const eventDetails = computed(() => [
|
||
{
|
||
label: t('common.date'),
|
||
value: bookingConfig.event.dateLabel,
|
||
icon: 'lucide:calendar-days'
|
||
},
|
||
{
|
||
label: t('common.time'),
|
||
value: bookingConfig.event.timeLabel,
|
||
icon: 'lucide:clock-6'
|
||
},
|
||
{
|
||
label: t('common.venue'),
|
||
value: bookingConfig.event.venue,
|
||
icon: 'lucide:map-pin'
|
||
}
|
||
])
|
||
|
||
const bookingModeOptions = computed(() => {
|
||
return bookingConfig.bookingModes.map((mode) => ({
|
||
value: mode.value,
|
||
label: locale.value === 'zh' ? translateBookingModeLabel(mode.label) : mode.label
|
||
}))
|
||
})
|
||
|
||
const ticketCatalogOptions = computed(() => {
|
||
return bookingConfig.ticketCatalog.map((ticket) => ({
|
||
value: ticket.value,
|
||
label: locale.value === 'zh' ? translateTicketLabel(ticket.label) : ticket.label,
|
||
description: locale.value === 'zh' ? translateTicketDescription(ticket.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(() => {
|
||
if (selectedBookingMode.value?.quantityLabel) {
|
||
return locale.value === 'zh'
|
||
? translateBookingModeQuantityLabel(selectedBookingMode.value.quantityLabel)
|
||
: selectedBookingMode.value.quantityLabel
|
||
}
|
||
|
||
return t('booking.quantity')
|
||
})
|
||
|
||
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
|
||
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
|
||
|
||
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value, locale.value))
|
||
|
||
function translateBookingModeLabel(label: string) {
|
||
const normalized = label.toLowerCase()
|
||
|
||
if (normalized.includes('table')) {
|
||
return '桌席(10 个座位)'
|
||
}
|
||
|
||
if (normalized.includes('seat')) {
|
||
return '座位'
|
||
}
|
||
|
||
return label
|
||
}
|
||
|
||
function translateBookingModeQuantityLabel(label: string) {
|
||
const normalized = label.toLowerCase()
|
||
|
||
if (normalized.includes('table')) {
|
||
return '桌数'
|
||
}
|
||
|
||
if (normalized.includes('seat')) {
|
||
return '座位数量'
|
||
}
|
||
|
||
return label
|
||
}
|
||
|
||
function translateTicketLabel(label: string) {
|
||
const normalized = label.toLowerCase()
|
||
|
||
if (normalized.includes('supporter')) {
|
||
return '支持者'
|
||
}
|
||
|
||
return label
|
||
}
|
||
|
||
function translateTicketDescription(description: string) {
|
||
return description.replace(/seat/gi, locale.value === 'zh' ? '座位' : 'seat')
|
||
}
|
||
|
||
function validateBooking(state: typeof form): FormError[] {
|
||
const errors: FormError[] = []
|
||
|
||
if (!state.name.trim()) {
|
||
errors.push({ name: 'name', message: t('booking.nameRequired') })
|
||
}
|
||
|
||
if (!state.phone.trim()) {
|
||
errors.push({ name: 'phone', message: t('booking.phoneRequired') })
|
||
} else if (!isValidPhoneNumber(state.phone.trim())) {
|
||
errors.push({ name: 'phone', message: t('booking.phoneInvalid') })
|
||
}
|
||
|
||
if (state.quantity < 1) {
|
||
errors.push({ name: 'quantity', message: t('booking.quantityMin', { label: quantityLabel.value }) })
|
||
}
|
||
|
||
if (!selectedBookingMode.value) {
|
||
errors.push({ name: 'bookingMode', message: t('booking.modeRequired') })
|
||
}
|
||
|
||
if (!selectedTicket.value) {
|
||
errors.push({ name: 'ticketType', message: t('booking.ticketRequired') })
|
||
}
|
||
|
||
return errors
|
||
}
|
||
|
||
async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||
const selectedPic = selectedPersonInChargeRecord.value
|
||
|
||
if (!selectedPic) {
|
||
toast.add({
|
||
title: t('booking.noPicTitle'),
|
||
description: t('booking.noPicDescription'),
|
||
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,
|
||
locale: locale.value
|
||
}
|
||
})
|
||
|
||
const bookingWindow = window.open(response.whatsappUrl, '_blank', 'noopener,noreferrer')
|
||
|
||
if (!bookingWindow) {
|
||
window.location.assign(response.whatsappUrl)
|
||
return
|
||
}
|
||
|
||
toast.add({
|
||
title: t('booking.whatsappOpened'),
|
||
description: t('booking.whatsappOpenedDescription', { name: selectedPic.fullName }),
|
||
color: 'success',
|
||
icon: 'i-lucide-check-circle-2'
|
||
})
|
||
} catch (error) {
|
||
toast.add({
|
||
title: t('booking.createFailed'),
|
||
description: getErrorMessage(error, t('booking.tryAgain')),
|
||
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="t('booking.name')" required>
|
||
<UInput v-model="form.name" size="xl" class="w-full" :placeholder="t('booking.namePlaceholder')" />
|
||
</UFormField>
|
||
|
||
<UFormField name="phone" :label="t('common.phoneNumber')" required>
|
||
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" :placeholder="t('booking.phonePlaceholder')" />
|
||
</UFormField>
|
||
</div>
|
||
|
||
<UFormField :label="t('booking.bookingMode')" 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>
|
||
{{ t('booking.seatGeneration', { count: seatCount, seatLabel: locale === 'zh' ? '座位' : `seat${seatCount === 1 ? '' : 's'}` }) }}
|
||
</template>
|
||
</UFormField>
|
||
|
||
<UFormField :label="t('booking.ticketCategory')" 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">{{ t('common.totalPrice') }}</span>
|
||
<span class="text-2xl font-bold text-highlighted">{{ totalFormatted }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<UFormField :label="t('booking.personInCharge')">
|
||
<USelect
|
||
v-model="selectedPersonInCharge"
|
||
size="xl"
|
||
class="w-full"
|
||
:items="personInCharge"
|
||
:disabled="!personInCharge.length"
|
||
/>
|
||
</UFormField>
|
||
|
||
<UButton id="getTicketBtn" type="submit" :label="t('booking.bookNow')" size="xl"
|
||
class="w-full justify-center" :disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
|
||
</UForm>
|
||
</UCard>
|
||
</div>
|
||
</UContainer>
|
||
</template>
|