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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user