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:
364
app/composables/useLocale.ts
Normal file
364
app/composables/useLocale.ts
Normal file
@@ -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<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
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
||||
Dinner Ticket System
|
||||
{{ t('layout.brand') }}
|
||||
</div>
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ auth.user.value.fullName }}
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<UButton
|
||||
to="/"
|
||||
label="Public"
|
||||
:label="t('common.public')"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-house"
|
||||
@@ -113,9 +113,19 @@
|
||||
@click="mobileMenuOpen = false"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:label="currentLocaleOption.label"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-languages"
|
||||
class="w-full justify-start rounded-2xl"
|
||||
:aria-label="t('common.switchLanguage')"
|
||||
@click="toggleLocale"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:loading="logoutPending"
|
||||
label="Logout"
|
||||
:label="t('common.logout')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-log-out"
|
||||
@@ -136,7 +146,7 @@
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
||||
Dinner Ticket System
|
||||
{{ t('layout.brand') }}
|
||||
</div>
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ auth.user.value.fullName }}
|
||||
@@ -196,16 +206,26 @@
|
||||
<div class="grid gap-2">
|
||||
<UButton
|
||||
to="/"
|
||||
label="Public"
|
||||
:label="t('common.public')"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-house"
|
||||
class="justify-start rounded-2xl"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:label="currentLocaleOption.label"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-languages"
|
||||
class="justify-start rounded-2xl"
|
||||
:aria-label="t('common.switchLanguage')"
|
||||
@click="toggleLocale"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
:loading="logoutPending"
|
||||
label="Logout"
|
||||
:label="t('common.logout')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-log-out"
|
||||
@@ -227,7 +247,7 @@
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
||||
Dinner Ticket System
|
||||
{{ t('layout.brand') }}
|
||||
</div>
|
||||
<div class="truncate text-sm font-semibold text-highlighted">
|
||||
{{ 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 @@
|
||||
|
||||
<footer class="border-t border-default bg-default/96">
|
||||
<UContainer class="py-5 text-center text-sm text-muted">
|
||||
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
||||
{{ t('layout.footer') }}
|
||||
</UContainer>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -273,20 +293,32 @@
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-semibold uppercase tracking-[0.24em] text-muted">
|
||||
Dinner Ticket System
|
||||
{{ t('layout.brand') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
id="loginBtn"
|
||||
:to="route.path.startsWith('/login') ? '/' : '/login'"
|
||||
:label="route.path.startsWith('/login') ? 'Back' : 'Login'"
|
||||
color="neutral"
|
||||
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
||||
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
||||
class="rounded-full"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-languages"
|
||||
:label="currentLocaleOption.shortLabel"
|
||||
class="rounded-full"
|
||||
:aria-label="t('common.switchLanguage')"
|
||||
@click="toggleLocale"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
id="loginBtn"
|
||||
:to="route.path.startsWith('/login') ? '/' : '/login'"
|
||||
:label="route.path.startsWith('/login') ? t('common.back') : t('common.login')"
|
||||
color="neutral"
|
||||
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
||||
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</header>
|
||||
@@ -297,7 +329,7 @@
|
||||
|
||||
<footer class="border-t border-default bg-default/96">
|
||||
<UContainer class="py-5 text-center text-sm text-muted">
|
||||
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
||||
{{ t('layout.footer') }}
|
||||
</UContainer>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
<UContainer class="py-8">
|
||||
<div class="mx-auto max-w-3xl space-y-5">
|
||||
<div class="space-y-2 text-center">
|
||||
<UBadge label="PIC Confirmation" color="primary" variant="soft" class="rounded-full" />
|
||||
<UBadge :label="t('confirm.badge')" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
|
||||
Review Booking Details
|
||||
{{ t('confirm.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
Confirm the booking after verifying the details below.
|
||||
{{ t('confirm.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -178,20 +179,20 @@ async function cancelBookingConfirmation() {
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-muted">
|
||||
Booking status
|
||||
{{ t('confirm.status') }}
|
||||
</p>
|
||||
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel)" :color="statusColor" variant="soft" />
|
||||
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel, locale)" :color="statusColor" variant="soft" />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted">
|
||||
Submitted {{ formatDateTime(booking.createdAt) }}
|
||||
{{ t('confirm.submitted', { date: formatDateTime(booking.createdAt, t('date.notAvailable'), locale) }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="booking.status === 'confirmed'"
|
||||
title="Booking already confirmed"
|
||||
:description="`Confirmed on ${formatDateTime(booking.confirmedAt)}.`"
|
||||
:title="t('confirm.alreadyConfirmed')"
|
||||
:description="t('confirm.confirmedOn', { date: formatDateTime(booking.confirmedAt, t('date.notAvailable'), locale) })"
|
||||
color="success"
|
||||
icon="i-lucide-badge-check"
|
||||
/>
|
||||
@@ -212,7 +213,7 @@ async function cancelBookingConfirmation() {
|
||||
|
||||
<div class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 bg-primary/5 px-4 py-3 sm:grid-cols-[11rem_minmax(0,1fr)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-primary sm:text-sm sm:normal-case sm:tracking-normal">
|
||||
Total Price
|
||||
{{ t('common.totalPrice') }}
|
||||
</div>
|
||||
<div class="min-w-0 text-lg font-bold text-highlighted sm:text-xl">
|
||||
{{ totalFormatted }}
|
||||
@@ -223,7 +224,7 @@ async function cancelBookingConfirmation() {
|
||||
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
to="/"
|
||||
label="Back To Booking Form"
|
||||
:label="t('confirm.backToForm')"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="justify-center"
|
||||
@@ -232,7 +233,7 @@ async function cancelBookingConfirmation() {
|
||||
<UButton
|
||||
v-if="booking.status === 'confirmed'"
|
||||
:to="receiptPath"
|
||||
label="Open Ticket Receipt"
|
||||
:label="t('confirm.openReceipt')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-receipt"
|
||||
@@ -241,7 +242,7 @@ async function cancelBookingConfirmation() {
|
||||
|
||||
<UButton
|
||||
v-if="booking.status === 'pending'"
|
||||
label="Confirm This Booking"
|
||||
:label="t('confirm.confirmBooking')"
|
||||
icon="i-lucide-check-check"
|
||||
class="justify-center"
|
||||
:loading="confirming"
|
||||
@@ -250,7 +251,7 @@ async function cancelBookingConfirmation() {
|
||||
|
||||
<UButton
|
||||
v-else
|
||||
label="Cancel Confirmation"
|
||||
:label="t('confirm.cancelConfirmation')"
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-x-circle"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const toast = useToast()
|
||||
const apiClient = useApiClient()
|
||||
const { locale, t } = useLocale()
|
||||
|
||||
const [bookingConfig, contactsResponse] = await Promise.all([
|
||||
apiClient<PublicBookingConfig>('/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<TicketCatalogItem | null>(() => {
|
||||
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<typeof form>) {
|
||||
|
||||
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<typeof form>) {
|
||||
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<typeof form>) {
|
||||
}
|
||||
|
||||
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<typeof form>) {
|
||||
|
||||
<UForm :state="form" :validate="validateBooking" class="space-y-6" @submit="bookTicket">
|
||||
<div class="space-y-5">
|
||||
<UFormField name="name" label="Name" required>
|
||||
<UInput v-model="form.name" size="xl" class="w-full" placeholder="e.g. John Doe" />
|
||||
<UFormField name="name" :label="t('booking.name')" required>
|
||||
<UInput v-model="form.name" size="xl" class="w-full" :placeholder="t('booking.namePlaceholder')" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="phone" label="Phone Number" required>
|
||||
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. +60123456789" />
|
||||
<UFormField name="phone" :label="t('common.phoneNumber')" required>
|
||||
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" :placeholder="t('booking.phonePlaceholder')" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Booking Mode" name="bookingMode">
|
||||
<UFormField :label="t('booking.bookingMode')" name="bookingMode">
|
||||
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
|
||||
:items="bookingModeOptions" :ui="{
|
||||
fieldset: 'grid grid-cols-2 gap-3',
|
||||
@@ -228,11 +278,11 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
<UFormField :label="quantityLabel" name="quantity">
|
||||
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
|
||||
<template #help>
|
||||
This booking will generate {{ seatCount }} seat{{ seatCount === 1 ? '' : 's' }}.
|
||||
{{ t('booking.seatGeneration', { count: seatCount, seatLabel: locale === 'zh' ? '座位' : `seat${seatCount === 1 ? '' : 's'}` }) }}
|
||||
</template>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Ticket Category" name="ticketType">
|
||||
<UFormField :label="t('booking.ticketCategory')" name="ticketType">
|
||||
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
|
||||
:items="ticketCatalogOptions" :ui="{
|
||||
fieldset: 'grid grid-cols-2 gap-3',
|
||||
@@ -242,12 +292,12 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
<div class="rounded-xl border border-default bg-muted px-4 py-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="text-sm font-medium text-muted">Total Price</span>
|
||||
<span class="text-sm font-medium text-muted">{{ t('common.totalPrice') }}</span>
|
||||
<span class="text-2xl font-bold text-highlighted">{{ totalFormatted }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UFormField label="Person In Charge">
|
||||
<UFormField :label="t('booking.personInCharge')">
|
||||
<USelect
|
||||
v-model="selectedPersonInCharge"
|
||||
size="xl"
|
||||
@@ -257,7 +307,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton id="getTicketBtn" type="submit" label="Book Now" size="xl"
|
||||
<UButton id="getTicketBtn" type="submit" :label="t('booking.bookNow')" size="xl"
|
||||
class="w-full justify-center" :disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
|
||||
@@ -4,39 +4,39 @@
|
||||
<UCard class="border border-default bg-default shadow-sm">
|
||||
<template #header>
|
||||
<div class="space-y-2">
|
||||
<UBadge label="Staff Access" color="primary" variant="soft" class="rounded-full" />
|
||||
<UBadge :label="t('login.badge')" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-3xl font-bold text-highlighted">
|
||||
Login to the management system
|
||||
{{ t('login.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
|
||||
<UFormField name="username" label="Username" required>
|
||||
<UFormField name="username" :label="t('login.username')" required>
|
||||
<UInput
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
placeholder="Enter your username"
|
||||
:placeholder="t('login.usernamePlaceholder')"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="password" label="Password" required>
|
||||
<UFormField name="password" :label="t('login.password')" required>
|
||||
<UInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
placeholder="Enter your password"
|
||||
:placeholder="t('login.passwordPlaceholder')"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UCheckbox v-model="form.remember" label="Remember this device" />
|
||||
<UCheckbox v-model="form.remember" :label="t('login.remember')" />
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
label="Sign In"
|
||||
:label="t('login.signIn')"
|
||||
size="xl"
|
||||
:loading="passwordPending"
|
||||
class="w-full justify-center"
|
||||
@@ -45,13 +45,13 @@
|
||||
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-default" />
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted">or</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{{ t('login.or') }}</span>
|
||||
<div class="h-px flex-1 bg-default" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UButton
|
||||
label="Sign In With Passkey"
|
||||
:label="t('login.passkey')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xl"
|
||||
@@ -81,6 +81,7 @@ const toast = useToast()
|
||||
const router = useRouter()
|
||||
const auth = useAuth()
|
||||
const apiClient = useApiClient()
|
||||
const { t } = useLocale()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
@@ -94,11 +95,11 @@ function validateLogin(state: typeof form): FormError[] {
|
||||
const errors: FormError[] = []
|
||||
|
||||
if (!state.username.trim()) {
|
||||
errors.push({ name: 'username', message: 'Please enter your username.' })
|
||||
errors.push({ name: 'username', message: t('login.usernameRequired') })
|
||||
}
|
||||
|
||||
if (!state.password.trim()) {
|
||||
errors.push({ name: 'password', message: 'Please enter your password.' })
|
||||
errors.push({ name: 'password', message: t('login.passwordRequired') })
|
||||
}
|
||||
|
||||
return errors
|
||||
@@ -135,8 +136,8 @@ async function onSubmit(event: FormSubmitEvent<typeof form>) {
|
||||
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'
|
||||
})
|
||||
|
||||
@@ -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<ReceiptTabId>('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() {
|
||||
<UContainer class="py-6 sm:py-8">
|
||||
<div class="mx-auto max-w-5xl space-y-5">
|
||||
<div class="space-y-1 text-center">
|
||||
<UBadge label="Ticket Receipt" color="primary" variant="soft" class="rounded-full" />
|
||||
<UBadge :label="t('receipt.badge')" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
|
||||
{{ eventDetails.title }}
|
||||
</h1>
|
||||
@@ -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() {
|
||||
|
||||
<div class="grid w-full gap-2 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-default bg-elevated px-4 py-3 text-left">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Category
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
{{ t('common.category') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-semibold text-highlighted">
|
||||
{{ ticketLabel }}
|
||||
@@ -404,8 +413,8 @@ async function openBatchShare() {
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-default bg-elevated px-4 py-3 text-left">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Seats
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
{{ t('common.seats') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-semibold text-highlighted">
|
||||
{{ receipt.booking.seatCount }}
|
||||
@@ -413,17 +422,17 @@ async function openBatchShare() {
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-default bg-elevated px-4 py-3 text-left">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Price
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
{{ t('common.totalPrice') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-semibold text-highlighted">
|
||||
{{ formatBookingCurrency(receipt.booking.totalPrice) }}
|
||||
{{ formatBookingCurrency(receipt.booking.totalPrice, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
label="Share Seats"
|
||||
:label="t('receipt.shareSeats')"
|
||||
icon="i-lucide-share-2"
|
||||
class="w-full justify-center sm:w-auto"
|
||||
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
|
||||
@@ -464,13 +473,13 @@ async function openBatchShare() {
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UBadge :label="`${receipt.seats.length} seats`" color="neutral" variant="soft" />
|
||||
<UBadge :label="`${sharedSeats.length} shared`" color="primary" variant="soft" />
|
||||
<UBadge :label="`${availableSeats.length} available`" color="neutral" variant="soft" />
|
||||
<UBadge :label="locale === 'zh' ? `${receipt.seats.length} 个座位` : `${receipt.seats.length} seats`" color="neutral" variant="soft" />
|
||||
<UBadge :label="locale === 'zh' ? `${sharedSeats.length} ${t('receipt.shared')}` : `${sharedSeats.length} shared`" color="primary" variant="soft" />
|
||||
<UBadge :label="locale === 'zh' ? `${availableSeats.length} ${t('receipt.available')}` : `${availableSeats.length} available`" color="neutral" variant="soft" />
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
label="Batch Share"
|
||||
:label="t('receipt.batchShare')"
|
||||
icon="i-lucide-share-2"
|
||||
size="sm"
|
||||
class="justify-center"
|
||||
@@ -484,28 +493,30 @@ async function openBatchShare() {
|
||||
<UTable
|
||||
:data="receipt.seats"
|
||||
:columns="seatColumns"
|
||||
caption="Seats"
|
||||
:caption="t('common.seats')"
|
||||
class="min-w-[560px] sm:min-w-[720px]"
|
||||
>
|
||||
<template #seatNumber-cell="{ row }">
|
||||
<div class="min-w-0 space-y-0.5 py-0.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-semibold leading-tight text-highlighted sm:text-base">
|
||||
{{ getSeatLabel(row.original.seatNumber) }}
|
||||
{{ getSeatLabel(row.original.seatNumber, locale) }}
|
||||
</p>
|
||||
<UBadge
|
||||
:label="row.original.sharedAt ? 'Shared' : 'Available'"
|
||||
<UBadge
|
||||
:label="row.original.sharedAt ? t('receipt.sharedStatus') : t('receipt.availableStatus')"
|
||||
:color="row.original.sharedAt ? 'primary' : 'neutral'"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
{{ row.original.recipientName || 'Unassigned' }}
|
||||
{{ row.original.recipientName || t('receipt.unassigned') }}
|
||||
<span v-if="row.original.recipientPhone"> · {{ row.original.recipientPhone }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
{{ row.original.sharedAt ? `Shared ${formatDateTime(row.original.sharedAt)}` : 'Ready to share' }}
|
||||
{{ row.original.sharedAt
|
||||
? t('receipt.sharedAt', { date: formatDateTime(row.original.sharedAt, t('date.notAvailable'), locale) })
|
||||
: t('receipt.readyToShare') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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"
|
||||
/>
|
||||
<UButton
|
||||
:to="row.original.seatUrl"
|
||||
target="_blank"
|
||||
label="Open"
|
||||
:label="t('receipt.openLink')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-external-link"
|
||||
@@ -542,14 +553,14 @@ async function openBatchShare() {
|
||||
color="neutral"
|
||||
:variant="row.original.sharedAt ? 'outline' : 'solid'"
|
||||
size="sm"
|
||||
:aria-label="row.original.sharedAt ? 'Unshare seat' : 'Share seat'"
|
||||
:aria-label="row.original.sharedAt ? t('receipt.unshareSeat') : t('receipt.shareSeat')"
|
||||
class="min-w-10 justify-center px-2 sm:hidden"
|
||||
:loading="seatActionId === row.original.id"
|
||||
:disabled="shareSeatsLoading"
|
||||
@click="row.original.sharedAt ? unshareSeat(row.original) : shareSeat(row.original)"
|
||||
/>
|
||||
<UButton
|
||||
:label="row.original.sharedAt ? 'Unshare' : 'Share'"
|
||||
:label="row.original.sharedAt ? t('receipt.unshare') : t('receipt.share')"
|
||||
:icon="row.original.sharedAt ? 'i-lucide-user-minus' : 'i-lucide-share-2'"
|
||||
color="neutral"
|
||||
:variant="row.original.sharedAt ? 'outline' : 'solid'"
|
||||
@@ -567,7 +578,7 @@ async function openBatchShare() {
|
||||
|
||||
<UModal
|
||||
v-model:open="batchShareModalOpen"
|
||||
title="Batch Share Seats"
|
||||
:title="t('receipt.batchTitle')"
|
||||
:dismissible="!shareSeatsLoading"
|
||||
:close="!shareSeatsLoading"
|
||||
:content="{ class: 'sm:max-w-lg' }"
|
||||
@@ -577,7 +588,7 @@ async function openBatchShare() {
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-muted">
|
||||
Seats To Share
|
||||
{{ t('receipt.seatsToShare') }}
|
||||
</p>
|
||||
<UInputNumber
|
||||
v-model="shareForm.count"
|
||||
@@ -591,34 +602,34 @@ async function openBatchShare() {
|
||||
|
||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-muted">
|
||||
Next Seats
|
||||
{{ t('receipt.nextSeats') }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-medium text-highlighted break-words">
|
||||
{{ availableSeats.length ? seatsToShareLabel : 'No seats available' }}
|
||||
{{ availableSeats.length ? seatsToShareLabel : t('receipt.noSeatsAvailable') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UFormField label="Recipient Name">
|
||||
<UFormField :label="t('receipt.recipientName')">
|
||||
<UInput
|
||||
v-model="shareForm.recipientName"
|
||||
class="w-full"
|
||||
placeholder="Optional, e.g. +60123456789"
|
||||
:placeholder="t('receipt.optional')"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Recipient Phone">
|
||||
<UFormField :label="t('receipt.recipientPhone')">
|
||||
<UInput
|
||||
v-model="shareForm.recipientPhone"
|
||||
type="tel"
|
||||
class="w-full"
|
||||
placeholder="Optional"
|
||||
:placeholder="t('receipt.optionalPhonePlaceholder')"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
label="Cancel"
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="justify-center"
|
||||
@@ -627,7 +638,7 @@ async function openBatchShare() {
|
||||
/>
|
||||
|
||||
<UButton
|
||||
label="Share Seats"
|
||||
:label="t('receipt.shareSeats')"
|
||||
icon="i-lucide-share-2"
|
||||
class="justify-center"
|
||||
:disabled="!availableSeats.length || Boolean(seatActionId)"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export function formatDateTime(value: string | null, fallback = 'Not available') {
|
||||
import type { AppLocale } from '~~/shared/i18n'
|
||||
|
||||
export function formatDateTime(value: string | null, fallback = 'Not available', locale: AppLocale = 'en') {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-MY', {
|
||||
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-MY' : 'en-MY', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value))
|
||||
|
||||
Reference in New Issue
Block a user