Show customer name and phone number in a new panel Add English and Chinese translations for the purchaser label
737 lines
25 KiB
Vue
737 lines
25 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))
|
|
|
|
useSeoMeta({
|
|
title: () => `${t('receipt.badge')} - ${eventDetails.value.title}`,
|
|
description: () => `${receipt.value.booking.customerName} · ${ticketLabel.value} · ${receipt.value.booking.seatCount} ${t('common.seats')}`,
|
|
ogTitle: () => `${t('receipt.badge')} - ${eventDetails.value.title}`,
|
|
ogDescription: () => `${receipt.value.booking.customerName} · ${ticketLabel.value}`,
|
|
robots: 'noindex,nofollow'
|
|
})
|
|
|
|
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 px-4 py-3 text-left shadow-sm">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
{{ t('receipt.purchaser') }}
|
|
</p>
|
|
<div class="mt-2 space-y-2">
|
|
<div class="flex min-w-0 items-center gap-2 text-sm font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-user-round" class="size-4 shrink-0 text-muted" />
|
|
<span class="min-w-0 break-words">{{ receipt.booking.customerName }}</span>
|
|
</div>
|
|
<div class="flex min-w-0 items-center gap-2 text-sm text-default">
|
|
<UIcon name="i-lucide-phone" class="size-4 shrink-0 text-muted" />
|
|
<span class="min-w-0 break-words">{{ receipt.booking.customerPhone }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|