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:
2026-05-08 15:31:44 +08:00
parent b05cfd2c0e
commit 1318e766d5
14 changed files with 789 additions and 209 deletions

View File

@@ -12,6 +12,7 @@ import { formatDateTime } from '../../utils/formatters'
const route = useRoute()
const toast = useToast()
const apiClient = useApiClient()
const { locale, t } = useLocale()
const token = String(route.params.token || '')
const confirming = ref(false)
@@ -33,44 +34,44 @@ const booking = ref(initialBooking)
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
const ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice, locale.value))
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
const detailRows = computed(() => {
const rows = [
{
label: 'Guest / Organizer',
label: t('confirm.guestOrganizer'),
value: booking.value.customerName
},
{
label: 'Contact Number',
label: t('confirm.contactNumber'),
value: booking.value.customerPhone
},
{
label: 'Person In Charge',
label: t('confirm.pic'),
value: booking.value.personInChargeName
},
{
label: 'PIC Phone',
label: t('confirm.picPhone'),
value: booking.value.personInChargePhoneNumber
},
{
label: 'Ticket Category',
label: t('confirm.ticketCategory'),
value: ticketLabel.value
},
{
label: 'Seats Covered',
label: t('confirm.seatsCovered'),
value: String(booking.value.seatCount)
},
{
label: 'Submitted',
value: formatDateTime(booking.value.createdAt)
label: t('confirm.submittedLabel'),
value: formatDateTime(booking.value.createdAt, t('date.notAvailable'), locale.value)
}
]
if (booking.value.confirmedAt) {
rows.push({
label: 'Confirmed At',
value: formatDateTime(booking.value.confirmedAt)
label: t('confirm.confirmedAt'),
value: formatDateTime(booking.value.confirmedAt, t('date.notAvailable'), locale.value)
})
}
@@ -95,19 +96,19 @@ async function confirmBooking() {
booking.value = response.booking
toast.add({
title: response.alreadyConfirmed ? 'Booking already confirmed' : 'Booking confirmed',
title: response.alreadyConfirmed ? t('confirm.alreadyConfirmedToast') : t('confirm.confirmedToast'),
description: response.alreadyConfirmed
? 'This booking had already been confirmed earlier.'
? t('confirm.alreadyConfirmedDescription')
: response.ticketReceiptWhatsApp.sent
? `Ticket receipt was sent to ${response.ticketReceiptWhatsApp.recipientPhone}.`
: `Booking confirmed, but the ticket receipt WhatsApp was not sent: ${response.ticketReceiptWhatsApp.error}`,
? t('confirm.receiptSent', { phone: response.ticketReceiptWhatsApp.recipientPhone })
: t('confirm.receiptNotSent', { error: response.ticketReceiptWhatsApp.error }),
color: response.alreadyConfirmed || response.ticketReceiptWhatsApp.sent ? 'success' : 'warning',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {
toast.add({
title: 'Confirmation failed',
description: getErrorMessage(error, 'Please try again in a moment.'),
title: t('confirm.failed'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
@@ -121,7 +122,7 @@ async function cancelBookingConfirmation() {
return
}
if (import.meta.client && !window.confirm('Cancel this confirmation? The booking will return to pending and the seats will be released.')) {
if (import.meta.client && !window.confirm(t('confirm.cancelPrompt'))) {
return
}
@@ -138,17 +139,17 @@ async function cancelBookingConfirmation() {
booking.value = response.booking
toast.add({
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
title: response.alreadyPending ? t('confirm.alreadyPending') : t('confirm.cancelled'),
description: response.alreadyPending
? 'This booking was already pending confirmation.'
: 'The booking has been returned to pending status.',
? t('confirm.alreadyPendingDescription')
: t('confirm.cancelledDescription'),
color: response.alreadyPending ? 'warning' : 'success',
icon: 'i-lucide-x-circle'
})
} catch (error) {
toast.add({
title: 'Cancellation failed',
description: getErrorMessage(error, 'Please try again in a moment.'),
title: t('confirm.cancelFailed'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
@@ -162,12 +163,12 @@ async function cancelBookingConfirmation() {
<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" />
<UBadge :label="t('confirm.badge')" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
Review Booking Details
{{ t('confirm.title') }}
</h1>
<p class="text-sm text-muted">
Confirm the booking after verifying the details below.
{{ t('confirm.description') }}
</p>
</div>
@@ -178,20 +179,20 @@ async function cancelBookingConfirmation() {
<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
{{ t('confirm.status') }}
</p>
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel)" :color="statusColor" variant="soft" />
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel, locale)" :color="statusColor" variant="soft" />
</div>
<div class="text-sm text-muted">
Submitted {{ formatDateTime(booking.createdAt) }}
{{ t('confirm.submitted', { date: formatDateTime(booking.createdAt, t('date.notAvailable'), locale) }) }}
</div>
</div>
<UAlert
v-if="booking.status === 'confirmed'"
title="Booking already confirmed"
:description="`Confirmed on ${formatDateTime(booking.confirmedAt)}.`"
:title="t('confirm.alreadyConfirmed')"
:description="t('confirm.confirmedOn', { date: formatDateTime(booking.confirmedAt, t('date.notAvailable'), locale) })"
color="success"
icon="i-lucide-badge-check"
/>
@@ -212,7 +213,7 @@ async function cancelBookingConfirmation() {
<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
{{ t('common.totalPrice') }}
</div>
<div class="min-w-0 text-lg font-bold text-highlighted sm:text-xl">
{{ totalFormatted }}
@@ -223,7 +224,7 @@ async function cancelBookingConfirmation() {
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<UButton
to="/"
label="Back To Booking Form"
:label="t('confirm.backToForm')"
color="neutral"
variant="ghost"
class="justify-center"
@@ -232,7 +233,7 @@ async function cancelBookingConfirmation() {
<UButton
v-if="booking.status === 'confirmed'"
:to="receiptPath"
label="Open Ticket Receipt"
:label="t('confirm.openReceipt')"
color="neutral"
variant="outline"
icon="i-lucide-receipt"
@@ -241,7 +242,7 @@ async function cancelBookingConfirmation() {
<UButton
v-if="booking.status === 'pending'"
label="Confirm This Booking"
:label="t('confirm.confirmBooking')"
icon="i-lucide-check-check"
class="justify-center"
:loading="confirming"
@@ -250,7 +251,7 @@ async function cancelBookingConfirmation() {
<UButton
v-else
label="Cancel Confirmation"
:label="t('confirm.cancelConfirmation')"
color="error"
variant="outline"
icon="i-lucide-x-circle"