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

@@ -21,6 +21,7 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
quantity?: number
ticketType?: TicketType
personInChargeId?: string
locale?: string | null
}>(event)
const input = parseCreateBookingInput(body)
@@ -42,6 +43,7 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
eventId: bookingMode.eventId,
customerName: input.customerName,
customerPhone: input.customerPhone,
locale: input.locale,
bookingModeId: bookingMode.id,
bookingMode: bookingMode.value,
quantity: input.quantity,

View File

@@ -14,8 +14,10 @@ import type {
TicketCatalogItem,
TicketType
} from '~~/shared/booking'
import type { AppLocale } from '~~/shared/i18n'
import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus } from '~~/shared/booking'
import { resolveLocale } from '~~/shared/i18n'
import { randomToken, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init'
@@ -32,6 +34,7 @@ type DbBookingRow = {
event_venue: string
customer_name: string
customer_phone: string
locale: AppLocale | string | null
booking_mode_id: string | null
booking_mode: string
booking_mode_label: string | null
@@ -125,6 +128,7 @@ function bookingSelectColumns(sql: any) {
dinner_events.venue as event_venue,
bookings.customer_name,
bookings.customer_phone,
bookings.locale,
bookings.booking_mode_id,
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
booking_modes.label as booking_mode_label,
@@ -219,6 +223,7 @@ function mapBooking(row: DbBookingRow): PublicBooking {
event: mapDinnerEventFromBooking(row),
customerName: row.customer_name,
customerPhone: row.customer_phone,
locale: resolveLocale(row.locale),
bookingModeId: row.booking_mode_id,
bookingMode,
bookingModeLabel: row.booking_mode_label || bookingMode,
@@ -253,6 +258,7 @@ function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): Rec
event: mapDinnerEventFromBooking(row),
customerName: row.customer_name,
customerPhone: row.customer_phone,
locale: resolveLocale(row.locale),
bookingModeId: row.booking_mode_id,
bookingMode,
bookingModeLabel: row.booking_mode_label || bookingMode,
@@ -278,6 +284,7 @@ function mapPublicBookingToReceiptBooking(booking: PublicBooking): ReceiptBookin
event: booking.event,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
locale: booking.locale,
bookingModeId: booking.bookingModeId,
bookingMode: booking.bookingMode,
bookingModeLabel: booking.bookingModeLabel,
@@ -461,6 +468,7 @@ export async function createBooking(input: {
eventId: string
customerName: string
customerPhone: string
locale: AppLocale
bookingModeId: string
bookingMode: BookingMode
quantity: number
@@ -487,6 +495,7 @@ export async function createBooking(input: {
event_id,
customer_name,
customer_phone,
locale,
booking_mode_id,
booking_mode,
quantity,
@@ -506,6 +515,7 @@ export async function createBooking(input: {
${input.eventId},
${input.customerName},
${input.customerPhone},
${input.locale},
${input.bookingModeId},
${input.bookingMode},
${input.quantity},
@@ -748,6 +758,7 @@ export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
dinner_events.venue as event_venue,
bookings.customer_name,
bookings.customer_phone,
bookings.locale,
bookings.booking_mode_id,
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
booking_modes.label as booking_mode_label,

View File

@@ -4,6 +4,7 @@ import {
formatBookingCurrency
} from '~~/shared/booking'
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
import { resolveLocale } from '~~/shared/i18n'
import { assertBadRequest } from './http'
@@ -14,6 +15,7 @@ export function parseCreateBookingInput(body: {
quantity?: number
ticketType?: TicketType
personInChargeId?: string
locale?: string | null
}) {
const customerName = normalizeFullName(body.customerName || '')
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
@@ -21,6 +23,7 @@ export function parseCreateBookingInput(body: {
const ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : body.ticketType
const quantity = Number(body.quantity)
const personInChargeId = (body.personInChargeId || '').trim()
const locale = resolveLocale(body.locale)
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
@@ -35,7 +38,8 @@ export function parseCreateBookingInput(body: {
bookingMode,
quantity,
ticketType,
personInChargeId
personInChargeId,
locale
}
}
@@ -66,6 +70,21 @@ export function parseBookingPicTransferInput(body: {
}
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
if (booking.locale === 'zh') {
return [
`我想预订 ${booking.event.title} 的票券。`,
'',
`姓名:${booking.customerName}`,
`联络号码:${booking.customerPhone}`,
`座位:${booking.seatCount}`,
`票券类别:${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`总价:${formatBookingCurrency(booking.totalPrice, booking.locale)}`,
'',
'负责人确认链接:',
confirmationUrl
].join('\n')
}
return [
`I'd like to book tickets for the ${booking.event.title}.`,
'',
@@ -73,7 +92,7 @@ export function buildBookingMessage(booking: PublicBooking, confirmationUrl: str
`Phone Number: ${booking.customerPhone}`,
`Seats: ${booking.seatCount}`,
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice, booking.locale)}`,
'',
'PIC confirmation link:',
confirmationUrl

View File

@@ -272,6 +272,7 @@ async function initializeDatabase() {
event_id text references dinner_events(id) on delete restrict,
customer_name text not null,
customer_phone text not null,
locale text not null default 'en',
booking_mode_id text references booking_modes(id) on delete restrict,
booking_mode text not null,
quantity integer not null check (quantity >= 1),
@@ -316,6 +317,11 @@ async function initializeDatabase() {
add column if not exists remark text
`
await sql`
alter table bookings
add column if not exists locale text not null default 'en'
`
await sql`
create unique index if not exists bookings_receipt_token_idx
on bookings (receipt_token)

View File

@@ -26,6 +26,24 @@ export function buildWhatsAppDeepLink(phoneNumber: string, message: string) {
export function buildBookingTicketReceiptMessage(event: H3Event, booking: PublicBooking) {
const receiptUrl = buildAppUrl(event, `/receipt/${booking.receiptToken}`)
if (booking.locale === 'zh') {
return [
booking.event.title,
'',
`${booking.customerName} 您好,您的票券收据已确认。`,
'',
`收据:${receiptUrl}`,
`座位:${booking.seatCount}`,
`票券类别:${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`总价:${formatBookingCurrency(booking.totalPrice, booking.locale)}`,
`日期:${booking.event.dateLabel}`,
`时间:${booking.event.timeLabel}`,
`地点:${booking.event.venue}`,
'',
'请在活动当天出示收据中的二维码。'
].join('\n')
}
return [
booking.event.title,
'',
@@ -34,7 +52,7 @@ export function buildBookingTicketReceiptMessage(event: H3Event, booking: Public
`Receipt: ${receiptUrl}`,
`Seats: ${booking.seatCount}`,
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice, booking.locale)}`,
`Date: ${booking.event.dateLabel}`,
`Time: ${booking.event.timeLabel}`,
`Venue: ${booking.event.venue}`,