feat(i18n): add multi-language support (en/zh) across app and server

Implement useLocale composable and shared translation dictionaries
Translate public pages, booking flow, and receipt views
Store booking locale to send localized WhatsApp notifications
This commit is contained in:
2026-05-08 15:31:44 +08:00
parent b05cfd2c0e
commit 1318e766d5
14 changed files with 789 additions and 209 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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'
})

View File

@@ -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)"