diff --git a/app/composables/useLocale.ts b/app/composables/useLocale.ts new file mode 100644 index 0000000..17cd56c --- /dev/null +++ b/app/composables/useLocale.ts @@ -0,0 +1,364 @@ +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 + +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('dts-locale', { + sameSite: 'lax' + }) + const requestHeaders = import.meta.server ? useRequestHeaders(['accept-language']) : {} + const locale = useState('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 + } +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 65ab727..68272ca 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -40,7 +40,7 @@
- Dinner Ticket System + {{ t('layout.brand') }}
{{ auth.user.value.fullName }} @@ -105,7 +105,7 @@ + +
- Dinner Ticket System + {{ t('layout.brand') }}
{{ auth.user.value.fullName }} @@ -196,16 +206,26 @@
+ +
- Dinner Ticket System + {{ t('layout.brand') }}
{{ auth.user.value.fullName }} @@ -239,7 +259,7 @@ color="neutral" variant="outline" class="shrink-0 rounded-full" - :label="mobileMenuOpen ? 'Close' : 'Menu'" + :label="mobileMenuOpen ? t('common.close') : t('common.menu')" :icon="mobileMenuOpen ? 'i-lucide-x' : 'i-lucide-menu'" :aria-expanded="mobileMenuOpen" aria-label="Toggle navigation" @@ -255,7 +275,7 @@
- © 2026 DAP 60th Anniversary Committee. All rights reserved. + {{ t('layout.footer') }}
@@ -273,20 +293,32 @@
- Dinner Ticket System + {{ t('layout.brand') }}
- +
+ + + +
@@ -297,7 +329,7 @@
- © 2026 DAP 60th Anniversary Committee. All rights reserved. + {{ t('layout.footer') }}
@@ -308,7 +340,7 @@ import { getErrorMessage } from '../utils/errors' interface SystemMenuItem { - label: string + labelKey: 'nav.bookings' | 'nav.security' | 'nav.users' to: string icon: string requiresSuperAdmin?: boolean @@ -320,26 +352,33 @@ const router = useRouter() const toast = useToast() const auth = useAuth() const apiClient = useApiClient() +const { currentLocaleOption, locale, t, toggleLocale } = useLocale() const logoutPending = ref(false) const mobileMenuOpen = ref(false) await auth.fetchSession() +useHead(() => ({ + htmlAttrs: { + lang: locale.value === 'zh' ? 'zh-Hans' : 'en' + } +})) + const allSystemMenuItems: SystemMenuItem[] = [ { - label: 'Bookings', + labelKey: 'nav.bookings', to: '/bookings', icon: 'i-lucide-clipboard-list', matches: (path) => path.startsWith('/bookings') }, { - label: 'Security', + labelKey: 'nav.security', to: '/security', icon: 'i-lucide-shield-check', matches: (path) => path.startsWith('/security') }, { - label: 'Users', + labelKey: 'nav.users', to: '/management/users', icon: 'i-lucide-users', requiresSuperAdmin: true, @@ -348,13 +387,18 @@ const allSystemMenuItems: SystemMenuItem[] = [ ] const systemMenuItems = computed(() => { - return allSystemMenuItems.filter((item) => { - return !item.requiresSuperAdmin || auth.isSuperAdmin.value - }) + return allSystemMenuItems + .filter((item) => { + return !item.requiresSuperAdmin || auth.isSuperAdmin.value + }) + .map((item) => ({ + ...item, + label: t(item.labelKey) + })) }) const userRoleLabel = computed(() => { - return auth.isSuperAdmin.value ? 'Super Admin' : 'Staff' + return auth.isSuperAdmin.value ? t('role.superAdmin') : t('role.staff') }) function isMenuItemActive(item: SystemMenuItem) { @@ -405,15 +449,15 @@ async function logout() { await router.push('/login') toast.add({ - title: 'Signed out', - description: 'Your session has been cleared.', + title: t('layout.signedOut'), + description: t('layout.sessionCleared'), color: 'success', icon: 'i-lucide-check-circle-2' }) } catch (error: any) { toast.add({ - title: 'Logout failed', - description: getErrorMessage(error, 'Unable to end the current session.'), + title: t('layout.logoutFailed'), + description: getErrorMessage(error, t('layout.logoutFailedDescription')), color: 'error', icon: 'i-lucide-circle-alert' }) diff --git a/app/pages/confirmation/[token].vue b/app/pages/confirmation/[token].vue index e326dd0..e850a69 100644 --- a/app/pages/confirmation/[token].vue +++ b/app/pages/confirmation/[token].vue @@ -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() {
- +

- Review Booking Details + {{ t('confirm.title') }}

- Confirm the booking after verifying the details below. + {{ t('confirm.description') }}

@@ -178,20 +179,20 @@ async function cancelBookingConfirmation() {

- Booking status + {{ t('confirm.status') }}

- +
- Submitted {{ formatDateTime(booking.createdAt) }} + {{ t('confirm.submitted', { date: formatDateTime(booking.createdAt, t('date.notAvailable'), locale) }) }}
@@ -212,7 +213,7 @@ async function cancelBookingConfirmation() {
- Total Price + {{ t('common.totalPrice') }}
{{ totalFormatted }} @@ -223,7 +224,7 @@ async function cancelBookingConfirmation() {
('/api/public/booking-config'), @@ -28,17 +29,17 @@ const [bookingConfig, contactsResponse] = await Promise.all([ const eventDetails = computed(() => [ { - label: 'Date', + label: t('common.date'), value: bookingConfig.event.dateLabel, icon: 'lucide:calendar-days' }, { - label: 'Time', + label: t('common.time'), value: bookingConfig.event.timeLabel, icon: 'lucide:clock-6' }, { - label: 'Venue', + label: t('common.venue'), value: bookingConfig.event.venue, icon: 'lucide:map-pin' } @@ -47,15 +48,15 @@ const eventDetails = computed(() => [ const bookingModeOptions = computed(() => { return bookingConfig.bookingModes.map((mode) => ({ value: mode.value, - label: mode.label + label: locale.value === 'zh' ? translateBookingModeLabel(mode.label) : mode.label })) }) const ticketCatalogOptions = computed(() => { return bookingConfig.ticketCatalog.map((ticket) => ({ value: ticket.value, - label: ticket.label, - description: ticket.description + label: locale.value === 'zh' ? translateTicketLabel(ticket.label) : ticket.label, + description: locale.value === 'zh' ? translateTicketDescription(ticket.description) : ticket.description })) }) @@ -91,37 +92,85 @@ const selectedTicket = computed(() => { const submittingBooking = ref(false) const quantityLabel = computed(() => { - return selectedBookingMode.value?.quantityLabel || 'Quantity' + if (selectedBookingMode.value?.quantityLabel) { + return locale.value === 'zh' + ? translateBookingModeQuantityLabel(selectedBookingMode.value.quantityLabel) + : selectedBookingMode.value.quantityLabel + } + + return t('booking.quantity') }) const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity)) const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0)) -const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value)) +const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value, locale.value)) + +function translateBookingModeLabel(label: string) { + const normalized = label.toLowerCase() + + if (normalized.includes('table')) { + return '桌席(10 个座位)' + } + + if (normalized.includes('seat')) { + return '座位' + } + + return label +} + +function translateBookingModeQuantityLabel(label: string) { + const normalized = label.toLowerCase() + + if (normalized.includes('table')) { + return '桌数' + } + + if (normalized.includes('seat')) { + return '座位数量' + } + + return label +} + +function translateTicketLabel(label: string) { + const normalized = label.toLowerCase() + + if (normalized.includes('supporter')) { + return '支持者' + } + + return label +} + +function translateTicketDescription(description: string) { + return description.replace(/seat/gi, locale.value === 'zh' ? '座位' : 'seat') +} function validateBooking(state: typeof form): FormError[] { const errors: FormError[] = [] if (!state.name.trim()) { - errors.push({ name: 'name', message: 'Please enter the guest or organizer name.' }) + errors.push({ name: 'name', message: t('booking.nameRequired') }) } if (!state.phone.trim()) { - errors.push({ name: 'phone', message: 'Please enter a contact number.' }) + errors.push({ name: 'phone', message: t('booking.phoneRequired') }) } else if (!isValidPhoneNumber(state.phone.trim())) { - errors.push({ name: 'phone', message: 'Use a valid phone number with country code, e.g. +60123456789.' }) + errors.push({ name: 'phone', message: t('booking.phoneInvalid') }) } if (state.quantity < 1) { - errors.push({ name: 'quantity', message: `${quantityLabel.value} must be at least 1.` }) + errors.push({ name: 'quantity', message: t('booking.quantityMin', { label: quantityLabel.value }) }) } if (!selectedBookingMode.value) { - errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' }) + errors.push({ name: 'bookingMode', message: t('booking.modeRequired') }) } if (!selectedTicket.value) { - errors.push({ name: 'ticketType', message: 'Please select a ticket category.' }) + errors.push({ name: 'ticketType', message: t('booking.ticketRequired') }) } return errors @@ -132,8 +181,8 @@ async function bookTicket(event: FormSubmitEvent) { if (!selectedPic) { toast.add({ - title: 'No person in charge available', - description: 'Add a user with a phone number in the management page first.', + title: t('booking.noPicTitle'), + description: t('booking.noPicDescription'), color: 'error', icon: 'i-lucide-circle-alert' }) @@ -153,7 +202,8 @@ async function bookTicket(event: FormSubmitEvent) { bookingMode: form.bookingMode, quantity: form.quantity, ticketType: form.ticketType, - personInChargeId: selectedPic.id + personInChargeId: selectedPic.id, + locale: locale.value } }) @@ -165,15 +215,15 @@ async function bookTicket(event: FormSubmitEvent) { } toast.add({ - title: 'WhatsApp booking draft opened', - description: `Booking details and the confirmation link were sent to ${selectedPic.fullName}.`, + title: t('booking.whatsappOpened'), + description: t('booking.whatsappOpenedDescription', { name: selectedPic.fullName }), color: 'success', icon: 'i-lucide-check-circle-2' }) } catch (error) { toast.add({ - title: 'Booking could not be created', - description: getErrorMessage(error, 'Please try again in a moment.'), + title: t('booking.createFailed'), + description: getErrorMessage(error, t('booking.tryAgain')), color: 'error', icon: 'i-lucide-circle-alert' }) @@ -208,16 +258,16 @@ async function bookTicket(event: FormSubmitEvent) {
- - + + - - + +
- + - +
- Total Price + {{ t('common.totalPrice') }} {{ totalFormatted }}
- + ) { /> - diff --git a/app/pages/login/index.vue b/app/pages/login/index.vue index 328c6b9..fa556fc 100644 --- a/app/pages/login/index.vue +++ b/app/pages/login/index.vue @@ -4,39 +4,39 @@ - + - + - +
- or + {{ t('login.or') }}
) { await finishLogin(response.user) } catch (error: any) { toast.add({ - title: 'Login failed', - description: getErrorMessage(error, 'Unable to sign in with username and password.'), + title: t('login.failed'), + description: getErrorMessage(error, t('login.failedDescription')), color: 'error', icon: 'i-lucide-circle-alert' }) @@ -181,8 +182,8 @@ async function loginWithPasskey() { await finishLogin(verification.user) } catch (error: any) { toast.add({ - title: 'Passkey login failed', - description: getErrorMessage(error, 'Unable to complete passkey login.'), + title: t('login.passkeyFailed'), + description: getErrorMessage(error, t('login.passkeyFailedDescription')), color: 'error', icon: 'i-lucide-circle-alert' }) diff --git a/app/pages/receipt/[token].vue b/app/pages/receipt/[token].vue index b20804e..2be8771 100644 --- a/app/pages/receipt/[token].vue +++ b/app/pages/receipt/[token].vue @@ -15,6 +15,7 @@ type ReceiptTabId = 'main' | 'status' | 'seats' const route = useRoute() const toast = useToast() const apiClient = useApiClient() +const { locale, t } = useLocale() const token = String(route.params.token || '') const activeTab = ref('main') @@ -27,11 +28,11 @@ const shareForm = reactive({ recipientPhone: '' }) -const tabs = [ - { id: 'main' as const, label: 'Main QR', icon: 'i-lucide-qr-code' }, - { id: 'status' as const, label: 'Status', icon: 'i-lucide-badge-check' }, - { id: 'seats' as const, label: 'Seat List', icon: 'i-lucide-users' } -] +const tabs = computed(() => [ + { id: 'main' as const, label: t('receipt.mainQr'), icon: 'i-lucide-qr-code' }, + { id: 'status' as const, label: t('common.status'), icon: 'i-lucide-badge-check' }, + { id: 'seats' as const, label: t('receipt.seatList'), icon: 'i-lucide-users' } +]) let initialReceipt: PublicBookingReceipt @@ -56,39 +57,41 @@ const normalizedShareCount = computed(() => { return Math.max(1, Math.min(Math.trunc(Number(shareForm.count) || 1), maxShareCount.value)) }) const seatsToShare = computed(() => availableSeats.value.slice(0, normalizedShareCount.value)) -const seatsToShareLabel = computed(() => seatsToShare.value.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')) +const seatsToShareLabel = computed(() => seatsToShare.value.map((seat) => getSeatLabel(seat.seatNumber, locale.value)).join(', ')) const statusRows = computed(() => { return [ { - label: 'Status', - value: getBookingStatusLabel(receipt.value.booking.status, receipt.value.booking.statusLabel), + label: t('common.status'), + value: getBookingStatusLabel(receipt.value.booking.status, receipt.value.booking.statusLabel, locale.value), isBadge: true }, { - label: 'Guest', + label: t('receipt.guest'), value: receipt.value.booking.customerName }, { - label: 'Phone Number', + label: t('common.phoneNumber'), value: receipt.value.booking.customerPhone }, { - label: 'Category', + label: t('common.category'), value: ticketLabel.value }, { - label: 'Seats', - value: `${receipt.value.booking.seatCount} seat${receipt.value.booking.seatCount === 1 ? '' : 's'}` + label: t('common.seats'), + value: locale.value === 'zh' + ? `${receipt.value.booking.seatCount} 个座位` + : `${receipt.value.booking.seatCount} seat${receipt.value.booking.seatCount === 1 ? '' : 's'}` } ] }) -const seatColumns = [ - { accessorKey: 'seatNumber', header: 'Seat Detail' }, - { id: 'open', header: 'Open Link' }, - { id: 'share', header: 'Share' } -] +const seatColumns = computed(() => [ + { accessorKey: 'seatNumber', header: t('receipt.seatDetail') }, + { id: 'open', header: t('receipt.openLink') }, + { id: 'share', header: t('receipt.share') } +]) watch( availableSeats, @@ -115,25 +118,25 @@ function buildSeatBundleText( const recipientName = options?.recipientName?.trim() || '' const recipientPhone = options?.recipientPhone?.trim() || '' const recipientLabel = recipientName - ? `Recipient: ${recipientName}` + ? `${t('receipt.recipient')}: ${recipientName}` : null const recipientPhoneLabel = recipientPhone - ? `Recipient Phone: ${recipientPhone}` + ? `${t('receipt.recipientPhoneLabel')}: ${recipientPhone}` : null return [ eventDetails.value.title, - `Guest: ${receipt.value.booking.customerName}`, + `${t('receipt.guest')}: ${receipt.value.booking.customerName}`, recipientLabel, recipientPhoneLabel, - `Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`, - `Category: ${ticketLabel.value}`, - `Date: ${eventDetails.value.dateLabel}`, - `Time: ${eventDetails.value.timeLabel}`, - `Venue: ${eventDetails.value.venue}`, + `${t('common.seats')}: ${seats.map((seat) => getSeatLabel(seat.seatNumber, locale.value)).join(', ')}`, + `${t('common.category')}: ${ticketLabel.value}`, + `${t('common.date')}: ${eventDetails.value.dateLabel}`, + `${t('common.time')}: ${eventDetails.value.timeLabel}`, + `${t('common.venue')}: ${eventDetails.value.venue}`, '', ...seats.flatMap((seat) => [ - `${getSeatLabel(seat.seatNumber)}:`, + `${getSeatLabel(seat.seatNumber, locale.value)}:`, seat.seatUrl, '' ]) @@ -166,12 +169,12 @@ async function shareLink(options: { toast.add({ title: options.successTitle, - description: `${options.successDescription} Copied to clipboard.`, + description: `${options.successDescription} ${t('receipt.copied')}`, color: 'success', icon: 'i-lucide-copy-check' }) } else { - window.prompt('Copy this text', clipboardText) + window.prompt(t('receipt.copyPrompt'), clipboardText) } return true @@ -218,11 +221,14 @@ async function shareSeats() { recipientPhone: shareForm.recipientPhone }) const shared = await shareLink({ - title: `${eventDetails.value.title} seats`, + title: `${eventDetails.value.title} ${locale.value === 'zh' ? '座位' : 'seats'}`, text: shareText, clipboardText: shareText, - successTitle: 'Seats ready', - successDescription: `${seats.length} seat link${seats.length > 1 ? 's are' : ' is'} ready to send.` + successTitle: t('receipt.seatsReady'), + successDescription: t('receipt.seatsReadyDescription', { + count: seats.length, + seatLabel: locale.value === 'zh' ? '座位' : `seat link${seats.length > 1 ? 's are' : ' is'}` + }) }) if (!shared) { @@ -246,8 +252,8 @@ async function shareSeats() { if (!successCount) { toast.add({ - title: 'Seat update failed', - description: 'The share sheet opened, but the seat records could not be updated.', + title: t('receipt.seatUpdateFailed'), + description: t('receipt.seatUpdateFailedDescription'), color: 'error', icon: 'i-lucide-circle-alert' }) @@ -255,18 +261,21 @@ async function shareSeats() { } toast.add({ - title: `${successCount} seat${successCount > 1 ? 's' : ''} shared`, + title: t('receipt.seatsShared', { + count: successCount, + seatLabel: locale.value === 'zh' ? '座位' : `seat${successCount > 1 ? 's' : ''}` + }), description: successCount === seats.length - ? 'Next available seats were sent.' - : 'Some seats were sent, but at least one update failed.', + ? t('receipt.allSeatsSent') + : t('receipt.someSeatsFailed'), color: successCount === seats.length ? 'success' : 'warning', icon: successCount === seats.length ? 'i-lucide-check-check' : 'i-lucide-triangle-alert' }) return true } catch (error) { toast.add({ - title: 'Unable to share seats', - description: getErrorMessage(error, 'Please try again in a moment.'), + title: t('receipt.unableShareSeats'), + description: getErrorMessage(error, t('booking.tryAgain')), color: 'error', icon: 'i-lucide-circle-alert' }) @@ -285,11 +294,11 @@ async function shareSeat(seat: PublicBookingSeatWithUrl) { try { const shared = await shareLink({ - title: `${eventDetails.value.title} ${getSeatLabel(seat.seatNumber)}`, + title: `${eventDetails.value.title} ${getSeatLabel(seat.seatNumber, locale.value)}`, text: buildSeatBundleText([seat]), clipboardText: buildSeatBundleText([seat]), - successTitle: 'Seat ready', - successDescription: `${getSeatLabel(seat.seatNumber)} is ready to send.` + successTitle: t('receipt.seatReady'), + successDescription: t('receipt.seatReadyDescription', { seat: getSeatLabel(seat.seatNumber, locale.value) }) }) if (!shared) { @@ -299,14 +308,14 @@ async function shareSeat(seat: PublicBookingSeatWithUrl) { await patchSeatShare(seat, { shared: true }) toast.add({ - title: `${getSeatLabel(seat.seatNumber)} shared`, + title: t('receipt.seatShared', { seat: getSeatLabel(seat.seatNumber, locale.value) }), color: 'success', icon: 'i-lucide-share-2' }) } catch (error) { toast.add({ - title: 'Unable to share seat', - description: getErrorMessage(error, 'Please try again in a moment.'), + title: t('receipt.unableShareSeat'), + description: getErrorMessage(error, t('booking.tryAgain')), color: 'error', icon: 'i-lucide-circle-alert' }) @@ -320,7 +329,7 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) { return } - if (import.meta.client && !window.confirm(`Unshare ${getSeatLabel(seat.seatNumber)}? The previous link will stop working.`)) { + if (import.meta.client && !window.confirm(t('receipt.unsharePrompt', { seat: getSeatLabel(seat.seatNumber, locale.value) }))) { return } @@ -330,14 +339,14 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) { await patchSeatShare(seat, { shared: false }) toast.add({ - title: `${getSeatLabel(seat.seatNumber)} unshared`, + title: t('receipt.seatUnshared', { seat: getSeatLabel(seat.seatNumber, locale.value) }), color: 'success', icon: 'i-lucide-rotate-ccw' }) } catch (error) { toast.add({ - title: 'Unable to unshare seat', - description: getErrorMessage(error, 'Please try again in a moment.'), + title: t('receipt.unableUnshareSeat'), + description: getErrorMessage(error, t('booking.tryAgain')), color: 'error', icon: 'i-lucide-circle-alert' }) @@ -359,7 +368,7 @@ async function openBatchShare() {
- +

{{ eventDetails.title }}

@@ -372,7 +381,7 @@ async function openBatchShare() { :key="tab.id" type="button" class="flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2 text-xs font-medium transition sm:text-sm" - :class="activeTab === tab.id + :class="activeTab === tab.id ? 'bg-primary text-inverted shadow-sm' : 'bg-elevated text-default hover:bg-muted'" @click="activeTab = tab.id" @@ -395,8 +404,8 @@ async function openBatchShare() {
-

- Category +

+ {{ t('common.category') }}

{{ ticketLabel }} @@ -404,8 +413,8 @@ async function openBatchShare() {

-

- Seats +

+ {{ t('common.seats') }}

{{ receipt.booking.seatCount }} @@ -413,17 +422,17 @@ async function openBatchShare() {

-

- Price +

+ {{ t('common.totalPrice') }}

- {{ formatBookingCurrency(receipt.booking.totalPrice) }} + {{ formatBookingCurrency(receipt.booking.totalPrice, locale) }}

- - - + + +
@@ -519,13 +530,13 @@ async function openBatchShare() { variant="outline" icon="i-lucide-external-link" size="sm" - aria-label="Open seat link" + :aria-label="t('receipt.openSeatLink')" class="min-w-10 justify-center px-2 sm:hidden" />

- Seats To Share + {{ t('receipt.seatsToShare') }}

- Next Seats + {{ t('receipt.nextSeats') }}

- {{ availableSeats.length ? seatsToShareLabel : 'No seats available' }} + {{ availableSeats.length ? seatsToShareLabel : t('receipt.noSeatsAvailable') }}

- + - +
quantity?: number ticketType?: TicketType personInChargeId?: string + locale?: string | null }>(event) const input = parseCreateBookingInput(body) @@ -42,6 +43,7 @@ export default defineEventHandler(async (event): Promise eventId: bookingMode.eventId, customerName: input.customerName, customerPhone: input.customerPhone, + locale: input.locale, bookingModeId: bookingMode.id, bookingMode: bookingMode.value, quantity: input.quantity, diff --git a/server/utils/booking-repository.ts b/server/utils/booking-repository.ts index 70e44b0..7cd7670 100644 --- a/server/utils/booking-repository.ts +++ b/server/utils/booking-repository.ts @@ -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, diff --git a/server/utils/bookings.ts b/server/utils/bookings.ts index 32e06a7..03918bf 100644 --- a/server/utils/bookings.ts +++ b/server/utils/bookings.ts @@ -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 diff --git a/server/utils/db-init.ts b/server/utils/db-init.ts index 076986e..80da886 100644 --- a/server/utils/db-init.ts +++ b/server/utils/db-init.ts @@ -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) diff --git a/server/utils/whatsapp.ts b/server/utils/whatsapp.ts index d58d70f..afba791 100644 --- a/server/utils/whatsapp.ts +++ b/server/utils/whatsapp.ts @@ -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}`, diff --git a/shared/booking.ts b/shared/booking.ts index b304def..30ee118 100644 --- a/shared/booking.ts +++ b/shared/booking.ts @@ -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 = [ + { + 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' +}