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