Files
dticket.tootaio.com/app/composables/useLocale.ts
xiaomai 1318e766d5 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
2026-05-08 15:31:44 +08:00

365 lines
16 KiB
TypeScript

import {
DEFAULT_LOCALE,
LOCALE_OPTIONS,
getOppositeLocale,
resolveLocale,
type AppLocale
} from '~~/shared/i18n'
const messages = {
en: {
'common.back': 'Back',
'common.cancel': 'Cancel',
'common.category': 'Category',
'common.date': 'Date',
'common.login': 'Login',
'common.logout': 'Logout',
'common.menu': 'Menu',
'common.close': 'Close',
'common.open': 'Open',
'common.phoneNumber': 'Phone Number',
'common.price': 'Price',
'common.public': 'Public',
'common.refresh': 'Refresh',
'common.seats': 'Seats',
'common.status': 'Status',
'common.time': 'Time',
'common.totalPrice': 'Total Price',
'common.venue': 'Venue',
'common.switchLanguage': 'Switch language',
'common.language': 'Language',
'layout.brand': 'Dinner Ticket System',
'layout.footer': '© 2026 DAP 60th Anniversary Committee. All rights reserved.',
'layout.signedOut': 'Signed out',
'layout.sessionCleared': 'Your session has been cleared.',
'layout.logoutFailed': 'Logout failed',
'layout.logoutFailedDescription': 'Unable to end the current session.',
'nav.bookings': 'Bookings',
'nav.security': 'Security',
'nav.users': 'Users',
'role.superAdmin': 'Super Admin',
'role.staff': 'Staff',
'booking.name': 'Name',
'booking.namePlaceholder': 'e.g. John Doe',
'booking.phonePlaceholder': 'e.g. +60123456789',
'booking.bookingMode': 'Booking Mode',
'booking.quantity': 'Quantity',
'booking.ticketCategory': 'Ticket Category',
'booking.personInCharge': 'Person In Charge',
'booking.bookNow': 'Book Now',
'booking.seatGeneration': 'This booking will generate {count} {seatLabel}.',
'booking.noPicTitle': 'No person in charge available',
'booking.noPicDescription': 'Add a user with a phone number in the management page first.',
'booking.whatsappOpened': 'WhatsApp booking draft opened',
'booking.whatsappOpenedDescription': 'Booking details and the confirmation link were sent to {name}.',
'booking.createFailed': 'Booking could not be created',
'booking.tryAgain': 'Please try again in a moment.',
'booking.nameRequired': 'Please enter the guest or organizer name.',
'booking.phoneRequired': 'Please enter a contact number.',
'booking.phoneInvalid': 'Use a valid phone number with country code, e.g. +60123456789.',
'booking.quantityMin': '{label} must be at least 1.',
'booking.modeRequired': 'Please select a booking mode.',
'booking.ticketRequired': 'Please select a ticket category.',
'confirm.badge': 'PIC Confirmation',
'confirm.title': 'Review Booking Details',
'confirm.description': 'Confirm the booking after verifying the details below.',
'confirm.status': 'Booking status',
'confirm.submitted': 'Submitted {date}',
'confirm.alreadyConfirmed': 'Booking already confirmed',
'confirm.confirmedOn': 'Confirmed on {date}.',
'confirm.guestOrganizer': 'Guest / Organizer',
'confirm.contactNumber': 'Contact Number',
'confirm.pic': 'Person In Charge',
'confirm.picPhone': 'PIC Phone',
'confirm.ticketCategory': 'Ticket Category',
'confirm.seatsCovered': 'Seats Covered',
'confirm.submittedLabel': 'Submitted',
'confirm.confirmedAt': 'Confirmed At',
'confirm.backToForm': 'Back To Booking Form',
'confirm.openReceipt': 'Open Ticket Receipt',
'confirm.confirmBooking': 'Confirm This Booking',
'confirm.cancelConfirmation': 'Cancel Confirmation',
'confirm.alreadyConfirmedToast': 'Booking already confirmed',
'confirm.confirmedToast': 'Booking confirmed',
'confirm.alreadyConfirmedDescription': 'This booking had already been confirmed earlier.',
'confirm.receiptSent': 'Ticket receipt was sent to {phone}.',
'confirm.receiptNotSent': 'Booking confirmed, but the ticket receipt WhatsApp was not sent: {error}',
'confirm.failed': 'Confirmation failed',
'confirm.cancelPrompt': 'Cancel this confirmation? The booking will return to pending and the seats will be released.',
'confirm.alreadyPending': 'Booking already pending',
'confirm.cancelled': 'Confirmation cancelled',
'confirm.alreadyPendingDescription': 'This booking was already pending confirmation.',
'confirm.cancelledDescription': 'The booking has been returned to pending status.',
'confirm.cancelFailed': 'Cancellation failed',
'receipt.badge': 'Ticket Receipt',
'receipt.mainQr': 'Main QR',
'receipt.seatList': 'Seat List',
'receipt.shareSeats': 'Share Seats',
'receipt.batchShare': 'Batch Share',
'receipt.shared': 'shared',
'receipt.available': 'available',
'receipt.seatDetail': 'Seat Detail',
'receipt.openLink': 'Open Link',
'receipt.share': 'Share',
'receipt.sharedStatus': 'Shared',
'receipt.availableStatus': 'Available',
'receipt.unassigned': 'Unassigned',
'receipt.readyToShare': 'Ready to share',
'receipt.sharedAt': 'Shared {date}',
'receipt.openSeatLink': 'Open seat link',
'receipt.shareSeat': 'Share seat',
'receipt.unshareSeat': 'Unshare seat',
'receipt.unshare': 'Unshare',
'receipt.batchTitle': 'Batch Share Seats',
'receipt.seatsToShare': 'Seats To Share',
'receipt.nextSeats': 'Next Seats',
'receipt.noSeatsAvailable': 'No seats available',
'receipt.recipientName': 'Recipient Name',
'receipt.recipientPhone': 'Recipient Phone',
'receipt.optional': 'Optional',
'receipt.optionalPhonePlaceholder': 'Optional, e.g. +60123456789',
'receipt.recipient': 'Recipient',
'receipt.recipientPhoneLabel': 'Recipient Phone',
'receipt.guest': 'Guest',
'receipt.seatsReady': 'Seats ready',
'receipt.seatsReadyDescription': '{count} {seatLabel} ready to send.',
'receipt.copied': 'Copied to clipboard.',
'receipt.copyPrompt': 'Copy this text',
'receipt.seatUpdateFailed': 'Seat update failed',
'receipt.seatUpdateFailedDescription': 'The share sheet opened, but the seat records could not be updated.',
'receipt.seatsShared': '{count} {seatLabel} shared',
'receipt.allSeatsSent': 'Next available seats were sent.',
'receipt.someSeatsFailed': 'Some seats were sent, but at least one update failed.',
'receipt.unableShareSeats': 'Unable to share seats',
'receipt.seatReady': 'Seat ready',
'receipt.seatReadyDescription': '{seat} is ready to send.',
'receipt.seatShared': '{seat} shared',
'receipt.unableShareSeat': 'Unable to share seat',
'receipt.unsharePrompt': 'Unshare {seat}? The previous link will stop working.',
'receipt.seatUnshared': '{seat} unshared',
'receipt.unableUnshareSeat': 'Unable to unshare seat',
'login.badge': 'Staff Access',
'login.title': 'Login to the management system',
'login.username': 'Username',
'login.usernamePlaceholder': 'Enter your username',
'login.password': 'Password',
'login.passwordPlaceholder': 'Enter your password',
'login.remember': 'Remember this device',
'login.signIn': 'Sign In',
'login.or': 'or',
'login.passkey': 'Sign In With Passkey',
'login.usernameRequired': 'Please enter your username.',
'login.passwordRequired': 'Please enter your password.',
'login.failed': 'Login failed',
'login.failedDescription': 'Unable to sign in with username and password.',
'login.passkeyFailed': 'Passkey login failed',
'login.passkeyFailedDescription': 'Unable to complete passkey login.',
'date.notAvailable': 'Not available'
},
zh: {
'common.back': '返回',
'common.cancel': '取消',
'common.category': '类别',
'common.date': '日期',
'common.login': '登录',
'common.logout': '退出登录',
'common.menu': '菜单',
'common.close': '关闭',
'common.open': '打开',
'common.phoneNumber': '联络号码',
'common.price': '价格',
'common.public': '公开页面',
'common.refresh': '刷新',
'common.seats': '座位',
'common.status': '状态',
'common.time': '时间',
'common.totalPrice': '总价',
'common.venue': '地点',
'common.switchLanguage': '切换语言',
'common.language': '语言',
'layout.brand': '晚宴票券系统',
'layout.footer': '© 2026 DAP 60周年委员会。版权所有。',
'layout.signedOut': '已退出登录',
'layout.sessionCleared': '您的登录会话已清除。',
'layout.logoutFailed': '退出登录失败',
'layout.logoutFailedDescription': '无法结束当前会话。',
'nav.bookings': '预订',
'nav.security': '安全设置',
'nav.users': '用户',
'role.superAdmin': '超级管理员',
'role.staff': '职员',
'booking.name': '姓名',
'booking.namePlaceholder': '例如:陈大文',
'booking.phonePlaceholder': '例如:+60123456789',
'booking.bookingMode': '预订方式',
'booking.quantity': '数量',
'booking.ticketCategory': '票券类别',
'booking.personInCharge': '负责人',
'booking.bookNow': '立即预订',
'booking.seatGeneration': '此预订将生成 {count} 个{seatLabel}。',
'booking.noPicTitle': '没有可用负责人',
'booking.noPicDescription': '请先在管理页面新增一个有电话号码的用户。',
'booking.whatsappOpened': 'WhatsApp 预订草稿已打开',
'booking.whatsappOpenedDescription': '预订详情和确认链接已发送给 {name}。',
'booking.createFailed': '无法创建预订',
'booking.tryAgain': '请稍后再试。',
'booking.nameRequired': '请输入宾客或订桌人姓名。',
'booking.phoneRequired': '请输入联络号码。',
'booking.phoneInvalid': '请输入包含国家区号的有效号码,例如 +60123456789。',
'booking.quantityMin': '{label} 至少为 1。',
'booking.modeRequired': '请选择预订方式。',
'booking.ticketRequired': '请选择票券类别。',
'confirm.badge': '负责人确认',
'confirm.title': '检查预订详情',
'confirm.description': '请核对以下资料后确认预订。',
'confirm.status': '预订状态',
'confirm.submitted': '提交于 {date}',
'confirm.alreadyConfirmed': '预订已确认',
'confirm.confirmedOn': '确认于 {date}。',
'confirm.guestOrganizer': '宾客 / 订桌人',
'confirm.contactNumber': '联络号码',
'confirm.pic': '负责人',
'confirm.picPhone': '负责人电话',
'confirm.ticketCategory': '票券类别',
'confirm.seatsCovered': '座位数量',
'confirm.submittedLabel': '提交时间',
'confirm.confirmedAt': '确认时间',
'confirm.backToForm': '返回预订表格',
'confirm.openReceipt': '打开票券收据',
'confirm.confirmBooking': '确认此预订',
'confirm.cancelConfirmation': '取消确认',
'confirm.alreadyConfirmedToast': '预订已确认',
'confirm.confirmedToast': '预订已确认',
'confirm.alreadyConfirmedDescription': '此预订之前已经确认。',
'confirm.receiptSent': '票券收据已发送至 {phone}。',
'confirm.receiptNotSent': '预订已确认,但 WhatsApp 票券收据未发送:{error}',
'confirm.failed': '确认失败',
'confirm.cancelPrompt': '确定取消此确认?预订会回到待确认状态,座位也会释放。',
'confirm.alreadyPending': '预订已是待确认',
'confirm.cancelled': '已取消确认',
'confirm.alreadyPendingDescription': '此预订已经处于待确认状态。',
'confirm.cancelledDescription': '预订已回到待确认状态。',
'confirm.cancelFailed': '取消失败',
'receipt.badge': '票券收据',
'receipt.mainQr': '主二维码',
'receipt.seatList': '座位列表',
'receipt.shareSeats': '分享座位',
'receipt.batchShare': '批量分享',
'receipt.shared': '已分享',
'receipt.available': '可分享',
'receipt.seatDetail': '座位详情',
'receipt.openLink': '打开链接',
'receipt.share': '分享',
'receipt.sharedStatus': '已分享',
'receipt.availableStatus': '可分享',
'receipt.unassigned': '未指定',
'receipt.readyToShare': '可分享',
'receipt.sharedAt': '分享于 {date}',
'receipt.openSeatLink': '打开座位链接',
'receipt.shareSeat': '分享座位',
'receipt.unshareSeat': '取消分享座位',
'receipt.unshare': '取消分享',
'receipt.batchTitle': '批量分享座位',
'receipt.seatsToShare': '要分享的座位',
'receipt.nextSeats': '接下来的座位',
'receipt.noSeatsAvailable': '没有可分享的座位',
'receipt.recipientName': '接收人姓名',
'receipt.recipientPhone': '接收人电话',
'receipt.optional': '选填',
'receipt.optionalPhonePlaceholder': '选填,例如 +60123456789',
'receipt.recipient': '接收人',
'receipt.recipientPhoneLabel': '接收人电话',
'receipt.guest': '宾客',
'receipt.seatsReady': '座位已准备好',
'receipt.seatsReadyDescription': '{count} 个{seatLabel}已准备发送。',
'receipt.copied': '已复制到剪贴板。',
'receipt.copyPrompt': '复制这段文字',
'receipt.seatUpdateFailed': '座位更新失败',
'receipt.seatUpdateFailedDescription': '分享面板已打开,但座位记录无法更新。',
'receipt.seatsShared': '已分享 {count} 个{seatLabel}',
'receipt.allSeatsSent': '接下来的可用座位已发送。',
'receipt.someSeatsFailed': '部分座位已发送,但至少一个座位更新失败。',
'receipt.unableShareSeats': '无法分享座位',
'receipt.seatReady': '座位已准备好',
'receipt.seatReadyDescription': '{seat} 已准备发送。',
'receipt.seatShared': '{seat} 已分享',
'receipt.unableShareSeat': '无法分享座位',
'receipt.unsharePrompt': '确定取消分享 {seat}?旧链接将无法继续使用。',
'receipt.seatUnshared': '{seat} 已取消分享',
'receipt.unableUnshareSeat': '无法取消分享座位',
'login.badge': '职员入口',
'login.title': '登录管理系统',
'login.username': '用户名',
'login.usernamePlaceholder': '请输入用户名',
'login.password': '密码',
'login.passwordPlaceholder': '请输入密码',
'login.remember': '记住此设备',
'login.signIn': '登录',
'login.or': '或',
'login.passkey': '使用 Passkey 登录',
'login.usernameRequired': '请输入用户名。',
'login.passwordRequired': '请输入密码。',
'login.failed': '登录失败',
'login.failedDescription': '无法使用用户名和密码登录。',
'login.passkeyFailed': 'Passkey 登录失败',
'login.passkeyFailedDescription': '无法完成 Passkey 登录。',
'date.notAvailable': '无资料'
}
} as const
type MessageKey = keyof typeof messages.en
type MessageParams = Record<string, string | number | null | undefined>
function interpolate(message: string, params?: MessageParams) {
if (!params) {
return message
}
return message.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? ''))
}
export function useLocale() {
const localeCookie = useCookie<AppLocale | null>('dts-locale', {
sameSite: 'lax'
})
const requestHeaders = import.meta.server ? useRequestHeaders(['accept-language']) : {}
const locale = useState<AppLocale>('dts-locale', () => resolveLocale(
localeCookie.value || requestHeaders['accept-language'],
DEFAULT_LOCALE
))
if (import.meta.client && !localeCookie.value) {
locale.value = resolveLocale(navigator.language, locale.value)
}
localeCookie.value = locale.value
function setLocale(nextLocale: string) {
locale.value = resolveLocale(nextLocale, locale.value)
localeCookie.value = locale.value
}
function toggleLocale() {
setLocale(getOppositeLocale(locale.value))
}
function t(key: MessageKey, params?: MessageParams) {
const message = messages[locale.value][key] || messages.en[key] || key
return interpolate(message, params)
}
const localeOptions = LOCALE_OPTIONS
const currentLocaleOption = computed(() => {
return localeOptions.find((option) => option.value === locale.value) || localeOptions[0]
})
return {
locale,
localeOptions,
currentLocaleOption,
setLocale,
toggleLocale,
t
}
}