Introduce structural CSS classes for page shells, headers, and surface cards Update primary theme color to red and neutral to zinc across the application
655 lines
22 KiB
Vue
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>
|