feat(i18n): add multi-language support (en/zh) across app and server
Implement useLocale composable and shared translation dictionaries Translate public pages, booking flow, and receipt views Store booking locale to send localized WhatsApp notifications
This commit is contained in:
@@ -20,6 +20,7 @@ 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'),
|
||||
@@ -28,17 +29,17 @@ const [bookingConfig, contactsResponse] = await Promise.all([
|
||||
|
||||
const eventDetails = computed(() => [
|
||||
{
|
||||
label: 'Date',
|
||||
label: t('common.date'),
|
||||
value: bookingConfig.event.dateLabel,
|
||||
icon: 'lucide:calendar-days'
|
||||
},
|
||||
{
|
||||
label: 'Time',
|
||||
label: t('common.time'),
|
||||
value: bookingConfig.event.timeLabel,
|
||||
icon: 'lucide:clock-6'
|
||||
},
|
||||
{
|
||||
label: 'Venue',
|
||||
label: t('common.venue'),
|
||||
value: bookingConfig.event.venue,
|
||||
icon: 'lucide:map-pin'
|
||||
}
|
||||
@@ -47,15 +48,15 @@ const eventDetails = computed(() => [
|
||||
const bookingModeOptions = computed(() => {
|
||||
return bookingConfig.bookingModes.map((mode) => ({
|
||||
value: mode.value,
|
||||
label: mode.label
|
||||
label: locale.value === 'zh' ? translateBookingModeLabel(mode.label) : mode.label
|
||||
}))
|
||||
})
|
||||
|
||||
const ticketCatalogOptions = computed(() => {
|
||||
return bookingConfig.ticketCatalog.map((ticket) => ({
|
||||
value: ticket.value,
|
||||
label: ticket.label,
|
||||
description: ticket.description
|
||||
label: locale.value === 'zh' ? translateTicketLabel(ticket.label) : ticket.label,
|
||||
description: locale.value === 'zh' ? translateTicketDescription(ticket.description) : ticket.description
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -91,37 +92,85 @@ const selectedTicket = computed<TicketCatalogItem | null>(() => {
|
||||
const submittingBooking = ref(false)
|
||||
|
||||
const quantityLabel = computed(() => {
|
||||
return selectedBookingMode.value?.quantityLabel || 'Quantity'
|
||||
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))
|
||||
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: 'Please enter the guest or organizer name.' })
|
||||
errors.push({ name: 'name', message: t('booking.nameRequired') })
|
||||
}
|
||||
|
||||
if (!state.phone.trim()) {
|
||||
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
|
||||
errors.push({ name: 'phone', message: t('booking.phoneRequired') })
|
||||
} else if (!isValidPhoneNumber(state.phone.trim())) {
|
||||
errors.push({ name: 'phone', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
|
||||
errors.push({ name: 'phone', message: t('booking.phoneInvalid') })
|
||||
}
|
||||
|
||||
if (state.quantity < 1) {
|
||||
errors.push({ name: 'quantity', message: `${quantityLabel.value} must be at least 1.` })
|
||||
errors.push({ name: 'quantity', message: t('booking.quantityMin', { label: quantityLabel.value }) })
|
||||
}
|
||||
|
||||
if (!selectedBookingMode.value) {
|
||||
errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' })
|
||||
errors.push({ name: 'bookingMode', message: t('booking.modeRequired') })
|
||||
}
|
||||
|
||||
if (!selectedTicket.value) {
|
||||
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
|
||||
errors.push({ name: 'ticketType', message: t('booking.ticketRequired') })
|
||||
}
|
||||
|
||||
return errors
|
||||
@@ -132,8 +181,8 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
if (!selectedPic) {
|
||||
toast.add({
|
||||
title: 'No person in charge available',
|
||||
description: 'Add a user with a phone number in the management page first.',
|
||||
title: t('booking.noPicTitle'),
|
||||
description: t('booking.noPicDescription'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -153,7 +202,8 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
bookingMode: form.bookingMode,
|
||||
quantity: form.quantity,
|
||||
ticketType: form.ticketType,
|
||||
personInChargeId: selectedPic.id
|
||||
personInChargeId: selectedPic.id,
|
||||
locale: locale.value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -165,15 +215,15 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'WhatsApp booking draft opened',
|
||||
description: `Booking details and the confirmation link were sent to ${selectedPic.fullName}.`,
|
||||
title: t('booking.whatsappOpened'),
|
||||
description: t('booking.whatsappOpenedDescription', { name: 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.'),
|
||||
title: t('booking.createFailed'),
|
||||
description: getErrorMessage(error, t('booking.tryAgain')),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
@@ -208,16 +258,16 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
<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 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="Phone Number" required>
|
||||
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. +60123456789" />
|
||||
<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="Booking Mode" name="bookingMode">
|
||||
<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',
|
||||
@@ -228,11 +278,11 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
<UFormField :label="quantityLabel" name="quantity">
|
||||
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
|
||||
<template #help>
|
||||
This booking will generate {{ seatCount }} seat{{ seatCount === 1 ? '' : 's' }}.
|
||||
{{ t('booking.seatGeneration', { count: seatCount, seatLabel: locale === 'zh' ? '座位' : `seat${seatCount === 1 ? '' : 's'}` }) }}
|
||||
</template>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Ticket Category" name="ticketType">
|
||||
<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',
|
||||
@@ -242,12 +292,12 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
<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-sm font-medium text-muted">{{ t('common.totalPrice') }}</span>
|
||||
<span class="text-2xl font-bold text-highlighted">{{ totalFormatted }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UFormField label="Person In Charge">
|
||||
<UFormField :label="t('booking.personInCharge')">
|
||||
<USelect
|
||||
v-model="selectedPersonInCharge"
|
||||
size="xl"
|
||||
@@ -257,7 +307,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton id="getTicketBtn" type="submit" label="Book Now" size="xl"
|
||||
<UButton id="getTicketBtn" type="submit" :label="t('booking.bookNow')" size="xl"
|
||||
class="w-full justify-center" :disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
|
||||
Reference in New Issue
Block a user