Implement useLocale composable and shared translation dictionaries Translate public pages, booking flow, and receipt views Store booking locale to send localized WhatsApp notifications
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="py-6 sm:py-8">
|
|
<div class="mx-auto max-w-5xl space-y-5">
|
|
<div class="space-y-1 text-center">
|
|
<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>
|
|
</div>
|
|
|
|
<div class="rounded-2xl border border-default bg-default p-1.5 shadow-sm">
|
|
<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-xl 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="border border-default bg-default shadow-sm"
|
|
: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="rounded-[1.75rem] border border-default bg-elevated p-5 shadow-sm">
|
|
<QrCodeSvg :value="receipt.receiptUrl" :size="240" />
|
|
</div>
|
|
|
|
<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">
|
|
{{ t('common.category') }}
|
|
</p>
|
|
<p class="mt-1 text-sm font-semibold text-highlighted">
|
|
{{ ticketLabel }}
|
|
</p>
|
|
</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">
|
|
{{ t('common.seats') }}
|
|
</p>
|
|
<p class="mt-1 text-sm font-semibold text-highlighted">
|
|
{{ receipt.booking.seatCount }}
|
|
</p>
|
|
</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">
|
|
{{ 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="border border-default bg-default shadow-sm"
|
|
:ui="{ body: 'p-0' }"
|
|
>
|
|
<div class="overflow-hidden rounded-2xl">
|
|
<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="border border-default bg-default shadow-sm"
|
|
: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="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="rounded-2xl border border-default bg-elevated 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="rounded-2xl border border-default bg-elevated 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>
|