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

@@ -1,3 +1,5 @@
import type { AppLocale } from './i18n'
export type BookingMode = string
export type TicketType = string
export type BookingStatus = 'pending' | 'confirmed'
@@ -41,6 +43,7 @@ export interface PublicBooking {
event: DinnerEvent
customerName: string
customerPhone: string
locale: AppLocale
bookingModeId: string | null
bookingMode: BookingMode
bookingModeLabel: string
@@ -68,6 +71,7 @@ export interface ReceiptBooking {
event: DinnerEvent
customerName: string
customerPhone: string
locale: AppLocale
bookingModeId: string | null
bookingMode: BookingMode
bookingModeLabel: string
@@ -158,11 +162,15 @@ export function isBookingStatus(value: string | null | undefined): value is Book
return value === 'pending' || value === 'confirmed'
}
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null) {
if (label) {
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null, locale: AppLocale = 'en') {
if (label && locale !== 'zh') {
return label
}
if (locale === 'zh') {
return value === 'confirmed' ? '已确认' : '等待负责人确认'
}
return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation'
}
@@ -178,8 +186,8 @@ export function getBookingTicketLabel(booking: Pick<PublicBooking | ReceiptBooki
return booking.ticketLabel || booking.ticketType.toUpperCase()
}
export function formatBookingCurrency(value: number) {
return new Intl.NumberFormat('en-MY', {
export function formatBookingCurrency(value: number, locale: AppLocale = 'en') {
return new Intl.NumberFormat(locale === 'zh' ? 'zh-MY' : 'en-MY', {
style: 'currency',
currency: 'MYR',
minimumFractionDigits: 0,
@@ -187,8 +195,8 @@ export function formatBookingCurrency(value: number) {
}).format(value)
}
export function getSeatLabel(seatNumber: number) {
return `Seat ${seatNumber}`
export function getSeatLabel(seatNumber: number, locale: AppLocale = 'en') {
return locale === 'zh' ? `座位 ${seatNumber}` : `Seat ${seatNumber}`
}
export function calculateBookingInventorySummary(

43
shared/i18n.ts Normal file
View File

@@ -0,0 +1,43 @@
export const SUPPORTED_LOCALES = ['en', 'zh'] as const
export type AppLocale = typeof SUPPORTED_LOCALES[number]
export const DEFAULT_LOCALE: AppLocale = 'en'
export const LOCALE_OPTIONS: Array<{
value: AppLocale
label: string
shortLabel: string
}> = [
{
value: 'en',
label: 'English',
shortLabel: 'EN'
},
{
value: 'zh',
label: '中文',
shortLabel: '中'
}
]
export function resolveLocale(value: string | null | undefined, fallback: AppLocale = DEFAULT_LOCALE): AppLocale {
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) {
return fallback
}
if (normalized === 'zh' || normalized.startsWith('zh-') || normalized.startsWith('cn')) {
return 'zh'
}
if (normalized === 'en' || normalized.startsWith('en-')) {
return 'en'
}
return fallback
}
export function getOppositeLocale(locale: AppLocale): AppLocale {
return locale === 'zh' ? 'en' : 'zh'
}