Files
dticket.tootaio.com/app/pages/receipt/[token].vue
xiaomai b6749bc5e7 feat(ui): improve mobile responsiveness and touch targets
Add mobile-optimized card view for seat lists on smaller screens
Increase minimum height for buttons and form items for better touch interaction
Adjust grid layouts, padding, and spacing across pages for mobile devices
2026-05-08 16:28:47 +08:00

712 lines
24 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-5 sm: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-12 flex-1 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium transition sm:px-3 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-4 sm:p-8' }"
>
<div class="mx-auto flex max-w-sm flex-col items-center gap-5 text-center">
<div class="surface-panel w-full rounded-lg p-3 shadow-sm sm:p-5">
<QrCodeSvg :value="receipt.receiptUrl" :size="220" />
</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="min-h-12 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="grid gap-3 p-3 sm:hidden">
<div
v-for="seat in receipt.seats"
:key="seat.id"
class="surface-panel rounded-lg p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold leading-tight text-highlighted">
{{ getSeatLabel(seat.seatNumber, locale) }}
</p>
<UBadge
:label="seat.sharedAt ? t('receipt.sharedStatus') : t('receipt.availableStatus')"
:color="seat.sharedAt ? 'primary' : 'neutral'"
variant="soft"
size="sm"
/>
</div>
<p class="mt-1 text-xs text-muted">
{{ seat.recipientName || t('receipt.unassigned') }}
<span v-if="seat.recipientPhone"> · {{ seat.recipientPhone }}</span>
</p>
<p class="mt-1 text-xs text-muted">
{{ seat.sharedAt
? t('receipt.sharedAt', { date: formatDateTime(seat.sharedAt, t('date.notAvailable'), locale) })
: t('receipt.readyToShare') }}
</p>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2">
<UButton
:to="seat.seatUrl"
target="_blank"
:label="t('common.open')"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
size="sm"
class="min-h-11 justify-center"
/>
<UButton
:label="seat.sharedAt ? t('receipt.unshare') : t('receipt.share')"
:icon="seat.sharedAt ? 'i-lucide-user-minus' : 'i-lucide-share-2'"
color="neutral"
:variant="seat.sharedAt ? 'outline' : 'solid'"
size="sm"
class="min-h-11 justify-center"
:loading="seatActionId === seat.id"
:disabled="shareSeatsLoading"
@click="seat.sharedAt ? unshareSeat(seat) : shareSeat(seat)"
/>
</div>
</div>
</div>
<div class="hidden overflow-x-auto sm:block">
<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="min-h-11 justify-center"
:disabled="shareSeatsLoading"
@click="batchShareModalOpen = false"
/>
<UButton
:label="t('receipt.shareSeats')"
icon="i-lucide-share-2"
class="min-h-11 justify-center"
:disabled="!availableSeats.length || Boolean(seatActionId)"
:loading="shareSeatsLoading"
@click="openBatchShare"
/>
</div>
</div>
</template>
</UModal>
</div>
</UContainer>
</template>