Files
dticket.tootaio.com/app/pages/receipt/[token].vue
xiaomai 227c64d346 refactor(ui): standardize page layouts and component styling
Introduce structural CSS classes for page shells, headers, and surface cards
Update primary theme color to red and neutral to zinc across the application
2026-05-08 16:25:42 +08:00

655 lines
22 KiB
Vue

<script lang="ts" setup>
import type { PublicBookingReceipt, PublicBookingSeatWithUrl } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingStatusLabel,
getSeatLabel
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
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')
const shareSeatsLoading = ref(false)
const seatActionId = ref<string | null>(null)
const batchShareModalOpen = ref(false)
const shareForm = reactive({
count: 1,
recipientName: '',
recipientPhone: ''
})
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
try {
initialReceipt = await apiClient<PublicBookingReceipt>(`/api/public/receipts/${token}`)
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.data?.statusCode || 404,
statusMessage: error?.data?.statusMessage || error?.message || 'Receipt not found'
})
}
const receipt = ref(initialReceipt)
const eventDetails = computed(() => receipt.value.booking.event)
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
const maxShareCount = computed(() => Math.max(availableSeats.value.length, 1))
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, locale.value)).join(', '))
const statusRows = computed(() => {
return [
{
label: t('common.status'),
value: getBookingStatusLabel(receipt.value.booking.status, receipt.value.booking.statusLabel, locale.value),
isBadge: true
},
{
label: t('receipt.guest'),
value: receipt.value.booking.customerName
},
{
label: t('common.phoneNumber'),
value: receipt.value.booking.customerPhone
},
{
label: t('common.category'),
value: ticketLabel.value
},
{
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 = computed(() => [
{ accessorKey: 'seatNumber', header: t('receipt.seatDetail') },
{ id: 'open', header: t('receipt.openLink') },
{ id: 'share', header: t('receipt.share') }
])
watch(
availableSeats,
(nextSeats) => {
shareForm.count = Math.max(1, Math.min(shareForm.count, Math.max(nextSeats.length, 1)))
},
{ immediate: true }
)
function updateSeat(nextSeat: PublicBookingSeatWithUrl) {
receipt.value = {
...receipt.value,
seats: receipt.value.seats.map((seat) => seat.id === nextSeat.id ? nextSeat : seat)
}
}
function buildSeatBundleText(
seats: PublicBookingSeatWithUrl[],
options?: {
recipientName?: string
recipientPhone?: string
}
) {
const recipientName = options?.recipientName?.trim() || ''
const recipientPhone = options?.recipientPhone?.trim() || ''
const recipientLabel = recipientName
? `${t('receipt.recipient')}: ${recipientName}`
: null
const recipientPhoneLabel = recipientPhone
? `${t('receipt.recipientPhoneLabel')}: ${recipientPhone}`
: null
return [
eventDetails.value.title,
`${t('receipt.guest')}: ${receipt.value.booking.customerName}`,
recipientLabel,
recipientPhoneLabel,
`${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, locale.value)}:`,
seat.seatUrl,
''
])
].filter(Boolean).join('\n')
}
async function shareLink(options: {
title: string
text: string
url?: string
clipboardText?: string
successTitle: string
successDescription: string
}) {
if (!import.meta.client) {
return false
}
const clipboardText = options.clipboardText || options.url || options.text
try {
if (navigator.share) {
await navigator.share({
title: options.title,
text: options.text,
url: options.url
})
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(clipboardText)
toast.add({
title: options.successTitle,
description: `${options.successDescription} ${t('receipt.copied')}`,
color: 'success',
icon: 'i-lucide-copy-check'
})
} else {
window.prompt(t('receipt.copyPrompt'), clipboardText)
}
return true
} catch (error: any) {
if (error?.name === 'AbortError') {
return false
}
throw error
}
}
async function patchSeatShare(
seat: PublicBookingSeatWithUrl,
body: {
shared: boolean
recipientName?: string
recipientPhone?: string
}
) {
const response = await apiClient<{ seat: PublicBookingSeatWithUrl }>(
`/api/public/receipts/${token}/seats/${seat.id}`,
{
method: 'PATCH',
body
}
)
updateSeat(response.seat)
return response.seat
}
async function shareSeats() {
if (!availableSeats.value.length || shareSeatsLoading.value || seatActionId.value) {
return false
}
shareSeatsLoading.value = true
try {
const seats = seatsToShare.value
const shareText = buildSeatBundleText(seats, {
recipientName: shareForm.recipientName,
recipientPhone: shareForm.recipientPhone
})
const shared = await shareLink({
title: `${eventDetails.value.title} ${locale.value === 'zh' ? '座位' : 'seats'}`,
text: shareText,
clipboardText: shareText,
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) {
return false
}
let successCount = 0
for (const seat of seats) {
try {
await patchSeatShare(seat, {
shared: true,
recipientName: shareForm.recipientName,
recipientPhone: shareForm.recipientPhone
})
successCount += 1
} catch {
continue
}
}
if (!successCount) {
toast.add({
title: t('receipt.seatUpdateFailed'),
description: t('receipt.seatUpdateFailedDescription'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
return false
}
toast.add({
title: t('receipt.seatsShared', {
count: successCount,
seatLabel: locale.value === 'zh' ? '座位' : `seat${successCount > 1 ? 's' : ''}`
}),
description: successCount === seats.length
? 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: t('receipt.unableShareSeats'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
return false
} finally {
shareSeatsLoading.value = false
}
}
async function shareSeat(seat: PublicBookingSeatWithUrl) {
if (seatActionId.value || shareSeatsLoading.value || seat.sharedAt) {
return
}
seatActionId.value = seat.id
try {
const shared = await shareLink({
title: `${eventDetails.value.title} ${getSeatLabel(seat.seatNumber, locale.value)}`,
text: buildSeatBundleText([seat]),
clipboardText: buildSeatBundleText([seat]),
successTitle: t('receipt.seatReady'),
successDescription: t('receipt.seatReadyDescription', { seat: getSeatLabel(seat.seatNumber, locale.value) })
})
if (!shared) {
return
}
await patchSeatShare(seat, { shared: true })
toast.add({
title: t('receipt.seatShared', { seat: getSeatLabel(seat.seatNumber, locale.value) }),
color: 'success',
icon: 'i-lucide-share-2'
})
} catch (error) {
toast.add({
title: t('receipt.unableShareSeat'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
seatActionId.value = null
}
}
async function unshareSeat(seat: PublicBookingSeatWithUrl) {
if (seatActionId.value || shareSeatsLoading.value) {
return
}
if (import.meta.client && !window.confirm(t('receipt.unsharePrompt', { seat: getSeatLabel(seat.seatNumber, locale.value) }))) {
return
}
seatActionId.value = seat.id
try {
await patchSeatShare(seat, { shared: false })
toast.add({
title: t('receipt.seatUnshared', { seat: getSeatLabel(seat.seatNumber, locale.value) }),
color: 'success',
icon: 'i-lucide-rotate-ccw'
})
} catch (error) {
toast.add({
title: t('receipt.unableUnshareSeat'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
seatActionId.value = null
}
}
async function openBatchShare() {
const shared = await shareSeats()
if (shared) {
batchShareModalOpen.value = false
}
}
</script>
<template>
<UContainer class="page-shell-narrow">
<div class="space-y-6">
<div class="page-header text-center sm:items-center">
<UBadge :label="t('receipt.badge')" color="primary" variant="soft" class="page-eyebrow" />
<h1 class="page-title">
{{ eventDetails.title }}
</h1>
</div>
<div class="surface-card rounded-lg p-1.5">
<div class="flex gap-1.5">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium transition sm:text-sm"
:class="activeTab === tab.id
? 'bg-primary text-inverted shadow-sm'
: 'bg-elevated text-default hover:bg-muted'"
@click="activeTab = tab.id"
>
<UIcon :name="tab.icon" class="size-4" />
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<UCard
v-if="activeTab === 'main'"
class="surface-card overflow-hidden rounded-lg"
:ui="{ body: 'p-5 sm:p-8' }"
>
<div class="mx-auto flex max-w-sm flex-col items-center gap-5 text-center">
<div class="surface-panel rounded-lg p-5 shadow-sm">
<QrCodeSvg :value="receipt.receiptUrl" :size="240" />
</div>
<div class="grid w-full gap-2 sm:grid-cols-3">
<div class="surface-panel rounded-lg px-4 py-3 text-left">
<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 }}
</p>
</div>
<div class="surface-panel rounded-lg px-4 py-3 text-left">
<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 }}
</p>
</div>
<div class="surface-panel rounded-lg px-4 py-3 text-left">
<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, locale) }}
</p>
</div>
</div>
<UButton
:label="t('receipt.shareSeats')"
icon="i-lucide-share-2"
class="w-full justify-center sm:w-auto"
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
@click="batchShareModalOpen = true"
/>
</div>
</UCard>
<UCard
v-else-if="activeTab === 'status'"
class="surface-card overflow-hidden rounded-lg"
:ui="{ body: 'p-0' }"
>
<div class="overflow-hidden rounded-lg">
<div
v-for="row in statusRows"
:key="row.label"
class="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-3 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[9rem_minmax(0,1fr)]"
>
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
{{ row.label }}
</div>
<div v-if="row.isBadge" class="min-w-0">
<UBadge :label="row.value" :color="statusColor" variant="soft" />
</div>
<div v-else class="min-w-0 font-medium text-highlighted break-words">
{{ row.value }}
</div>
</div>
</div>
</UCard>
<UCard
v-else
class="surface-card overflow-hidden rounded-lg"
:ui="{ header: 'px-4 py-3', body: 'p-0' }"
>
<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="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="t('receipt.batchShare')"
icon="i-lucide-share-2"
size="sm"
class="justify-center"
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
@click="batchShareModalOpen = true"
/>
</div>
</template>
<div class="overflow-x-auto">
<UTable
:data="receipt.seats"
:columns="seatColumns"
:caption="t('common.seats')"
class="compact-table 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, locale) }}
</p>
<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 || t('receipt.unassigned') }}
<span v-if="row.original.recipientPhone"> · {{ row.original.recipientPhone }}</span>
</div>
<div class="text-xs text-muted">
{{ row.original.sharedAt
? t('receipt.sharedAt', { date: formatDateTime(row.original.sharedAt, t('date.notAvailable'), locale) })
: t('receipt.readyToShare') }}
</div>
</div>
</template>
<template #open-cell="{ row }">
<div class="py-0.5">
<UButton
:to="row.original.seatUrl"
target="_blank"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
size="sm"
:aria-label="t('receipt.openSeatLink')"
class="min-w-10 justify-center px-2 sm:hidden"
/>
<UButton
:to="row.original.seatUrl"
target="_blank"
:label="t('receipt.openLink')"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
size="sm"
class="hidden justify-center sm:inline-flex"
/>
</div>
</template>
<template #share-cell="{ row }">
<div class="py-0.5">
<UButton
:icon="row.original.sharedAt ? 'i-lucide-user-minus' : 'i-lucide-share-2'"
color="neutral"
:variant="row.original.sharedAt ? 'outline' : 'solid'"
size="sm"
: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 ? 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'"
size="sm"
class="hidden justify-center sm:inline-flex"
:loading="seatActionId === row.original.id"
:disabled="shareSeatsLoading"
@click="row.original.sharedAt ? unshareSeat(row.original) : shareSeat(row.original)"
/>
</div>
</template>
</UTable>
</div>
</UCard>
<UModal
v-model:open="batchShareModalOpen"
:title="t('receipt.batchTitle')"
:dismissible="!shareSeatsLoading"
:close="!shareSeatsLoading"
:content="{ class: 'sm:max-w-lg' }"
>
<template #body>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div class="surface-panel rounded-lg p-4">
<p class="text-xs uppercase tracking-wide text-muted">
{{ t('receipt.seatsToShare') }}
</p>
<UInputNumber
v-model="shareForm.count"
:min="1"
:max="maxShareCount"
:disabled="!availableSeats.length || shareSeatsLoading"
size="lg"
class="mt-3 w-full"
/>
</div>
<div class="surface-panel rounded-lg p-4">
<p class="text-xs uppercase tracking-wide text-muted">
{{ t('receipt.nextSeats') }}
</p>
<p class="mt-2 text-sm font-medium text-highlighted break-words">
{{ availableSeats.length ? seatsToShareLabel : t('receipt.noSeatsAvailable') }}
</p>
</div>
</div>
<UFormField :label="t('receipt.recipientName')">
<UInput
v-model="shareForm.recipientName"
class="w-full"
:placeholder="t('receipt.optional')"
/>
</UFormField>
<UFormField :label="t('receipt.recipientPhone')">
<UInput
v-model="shareForm.recipientPhone"
type="tel"
class="w-full"
:placeholder="t('receipt.optionalPhonePlaceholder')"
/>
</UFormField>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="ghost"
class="justify-center"
:disabled="shareSeatsLoading"
@click="batchShareModalOpen = false"
/>
<UButton
:label="t('receipt.shareSeats')"
icon="i-lucide-share-2"
class="justify-center"
:disabled="!availableSeats.length || Boolean(seatActionId)"
:loading="shareSeatsLoading"
@click="openBatchShare"
/>
</div>
</div>
</template>
</UModal>
</div>
</UContainer>
</template>