feat(receipt): redesign receipt page and seat sharing UI
Replace expandable seat list with a data table Move batch seat sharing into a dedicated modal Add ability to share individual seats directly Remove redundant share receipt link action
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
|||||||
DINNER_EVENT_TITLE,
|
DINNER_EVENT_TITLE,
|
||||||
DINNER_EVENT_VENUE,
|
DINNER_EVENT_VENUE,
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingModeLabel,
|
|
||||||
getBookingStatusLabel,
|
getBookingStatusLabel,
|
||||||
getSeatLabel,
|
getSeatLabel,
|
||||||
getTicketCatalogItem
|
getTicketCatalogItem
|
||||||
@@ -24,10 +23,9 @@ const apiClient = useApiClient()
|
|||||||
|
|
||||||
const token = String(route.params.token || '')
|
const token = String(route.params.token || '')
|
||||||
const activeTab = ref<ReceiptTabId>('main')
|
const activeTab = ref<ReceiptTabId>('main')
|
||||||
const receiptActionLoading = ref(false)
|
|
||||||
const shareSeatsLoading = ref(false)
|
const shareSeatsLoading = ref(false)
|
||||||
const seatActionId = ref<string | null>(null)
|
const seatActionId = ref<string | null>(null)
|
||||||
const expandedSeatIds = ref<string[]>([])
|
const batchShareModalOpen = ref(false)
|
||||||
const shareForm = reactive({
|
const shareForm = reactive({
|
||||||
count: 1,
|
count: 1,
|
||||||
recipientName: '',
|
recipientName: '',
|
||||||
@@ -54,7 +52,6 @@ try {
|
|||||||
const receipt = ref(initialReceipt)
|
const receipt = ref(initialReceipt)
|
||||||
|
|
||||||
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
||||||
const totalFormatted = computed(() => formatBookingCurrency(receipt.value.booking.totalPrice))
|
|
||||||
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
|
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
|
||||||
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
|
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
|
||||||
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
|
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
|
||||||
@@ -65,27 +62,38 @@ const normalizedShareCount = computed(() => {
|
|||||||
const seatsToShare = computed(() => availableSeats.value.slice(0, normalizedShareCount.value))
|
const seatsToShare = computed(() => availableSeats.value.slice(0, normalizedShareCount.value))
|
||||||
const seatsToShareLabel = computed(() => seatsToShare.value.map((seat) => getSeatLabel(seat.seatNumber)).join(', '))
|
const seatsToShareLabel = computed(() => seatsToShare.value.map((seat) => getSeatLabel(seat.seatNumber)).join(', '))
|
||||||
|
|
||||||
const detailRows = computed(() => {
|
const statusRows = computed(() => {
|
||||||
const rows = [
|
return [
|
||||||
{ label: 'Guest', value: receipt.value.booking.customerName },
|
{
|
||||||
{ label: 'Phone', value: receipt.value.booking.customerPhone },
|
label: 'Status',
|
||||||
{ label: 'Booking', value: getBookingModeLabel(receipt.value.booking.bookingMode) },
|
value: getBookingStatusLabel(receipt.value.booking.status),
|
||||||
{ label: 'Category', value: ticketLabel.value },
|
isBadge: true
|
||||||
{ label: 'Quantity', value: String(receipt.value.booking.quantity) },
|
},
|
||||||
{ label: 'Seats', value: String(receipt.value.booking.seatCount) },
|
{
|
||||||
{ label: 'Submitted', value: formatDateTime(receipt.value.booking.createdAt) }
|
label: 'Guest',
|
||||||
]
|
value: receipt.value.booking.customerName
|
||||||
|
},
|
||||||
if (receipt.value.booking.confirmedAt) {
|
{
|
||||||
rows.push({
|
label: 'Phone Number',
|
||||||
label: 'Confirmed',
|
value: receipt.value.booking.customerPhone
|
||||||
value: formatDateTime(receipt.value.booking.confirmedAt)
|
},
|
||||||
})
|
{
|
||||||
|
label: 'Category',
|
||||||
|
value: ticketLabel.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Seats',
|
||||||
|
value: `${receipt.value.booking.seatCount} seat${receipt.value.booking.seatCount === 1 ? '' : 's'}`
|
||||||
}
|
}
|
||||||
|
]
|
||||||
return rows
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const seatColumns = [
|
||||||
|
{ accessorKey: 'seatNumber', header: 'Seat Detail' },
|
||||||
|
{ id: 'open', header: 'Open Link' },
|
||||||
|
{ id: 'share', header: 'Share' }
|
||||||
|
]
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
availableSeats,
|
availableSeats,
|
||||||
(nextSeats) => {
|
(nextSeats) => {
|
||||||
@@ -101,25 +109,27 @@ function updateSeat(nextSeat: PublicBookingSeatWithUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSeatExpanded(seatId: string) {
|
function buildSeatBundleText(
|
||||||
return expandedSeatIds.value.includes(seatId)
|
seats: PublicBookingSeatWithUrl[],
|
||||||
|
options?: {
|
||||||
|
recipientName?: string
|
||||||
|
recipientPhone?: string
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
function toggleSeatExpanded(seatId: string) {
|
const recipientName = options?.recipientName?.trim() || ''
|
||||||
expandedSeatIds.value = isSeatExpanded(seatId)
|
const recipientPhone = options?.recipientPhone?.trim() || ''
|
||||||
? expandedSeatIds.value.filter((id) => id !== seatId)
|
const recipientLabel = recipientName
|
||||||
: [...expandedSeatIds.value, seatId]
|
? `Recipient: ${recipientName}`
|
||||||
}
|
: null
|
||||||
|
const recipientPhoneLabel = recipientPhone
|
||||||
function buildSeatBundleText(seats: PublicBookingSeatWithUrl[]) {
|
? `Recipient Phone: ${recipientPhone}`
|
||||||
const recipientLabel = shareForm.recipientName.trim()
|
|
||||||
? `Recipient: ${shareForm.recipientName.trim()}`
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DINNER_EVENT_TITLE,
|
DINNER_EVENT_TITLE,
|
||||||
`Guest: ${receipt.value.booking.customerName}`,
|
`Guest: ${receipt.value.booking.customerName}`,
|
||||||
recipientLabel,
|
recipientLabel,
|
||||||
|
recipientPhoneLabel,
|
||||||
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
|
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
|
||||||
`Category: ${ticketLabel.value}`,
|
`Category: ${ticketLabel.value}`,
|
||||||
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
||||||
@@ -198,60 +208,29 @@ async function patchSeatShare(
|
|||||||
return response.seat
|
return response.seat
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareReceiptLink() {
|
|
||||||
if (receiptActionLoading.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
receiptActionLoading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const shared = await shareLink({
|
|
||||||
title: `${DINNER_EVENT_TITLE} receipt`,
|
|
||||||
text: `Ticket receipt for ${receipt.value.booking.customerName}.`,
|
|
||||||
url: receipt.value.receiptUrl,
|
|
||||||
successTitle: 'Receipt ready',
|
|
||||||
successDescription: 'Main receipt link prepared.'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shared && import.meta.client && navigator.share) {
|
|
||||||
toast.add({
|
|
||||||
title: 'Receipt shared',
|
|
||||||
color: 'success',
|
|
||||||
icon: 'i-lucide-share-2'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.add({
|
|
||||||
title: 'Unable to share receipt',
|
|
||||||
description: getErrorMessage(error, 'Please try again in a moment.'),
|
|
||||||
color: 'error',
|
|
||||||
icon: 'i-lucide-circle-alert'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
receiptActionLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shareSeats() {
|
async function shareSeats() {
|
||||||
if (!availableSeats.value.length || shareSeatsLoading.value || seatActionId.value) {
|
if (!availableSeats.value.length || shareSeatsLoading.value || seatActionId.value) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
shareSeatsLoading.value = true
|
shareSeatsLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seats = seatsToShare.value
|
const seats = seatsToShare.value
|
||||||
|
const shareText = buildSeatBundleText(seats, {
|
||||||
|
recipientName: shareForm.recipientName,
|
||||||
|
recipientPhone: shareForm.recipientPhone
|
||||||
|
})
|
||||||
const shared = await shareLink({
|
const shared = await shareLink({
|
||||||
title: `${DINNER_EVENT_TITLE} seats`,
|
title: `${DINNER_EVENT_TITLE} seats`,
|
||||||
text: buildSeatBundleText(seats),
|
text: shareText,
|
||||||
clipboardText: buildSeatBundleText(seats),
|
clipboardText: shareText,
|
||||||
successTitle: 'Seats ready',
|
successTitle: 'Seats ready',
|
||||||
successDescription: `${seats.length} seat link${seats.length > 1 ? 's are' : ' is'} ready to send.`
|
successDescription: `${seats.length} seat link${seats.length > 1 ? 's are' : ' is'} ready to send.`
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!shared) {
|
if (!shared) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
@@ -276,7 +255,7 @@ async function shareSeats() {
|
|||||||
color: 'error',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
@@ -287,6 +266,7 @@ async function shareSeats() {
|
|||||||
color: successCount === seats.length ? 'success' : 'warning',
|
color: successCount === seats.length ? 'success' : 'warning',
|
||||||
icon: successCount === seats.length ? 'i-lucide-check-check' : 'i-lucide-triangle-alert'
|
icon: successCount === seats.length ? 'i-lucide-check-check' : 'i-lucide-triangle-alert'
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Unable to share seats',
|
title: 'Unable to share seats',
|
||||||
@@ -294,11 +274,51 @@ async function shareSeats() {
|
|||||||
color: 'error',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-alert'
|
icon: 'i-lucide-circle-alert'
|
||||||
})
|
})
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
shareSeatsLoading.value = false
|
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: `${DINNER_EVENT_TITLE} ${getSeatLabel(seat.seatNumber)}`,
|
||||||
|
text: buildSeatBundleText([seat]),
|
||||||
|
clipboardText: buildSeatBundleText([seat]),
|
||||||
|
successTitle: 'Seat ready',
|
||||||
|
successDescription: `${getSeatLabel(seat.seatNumber)} is ready to send.`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!shared) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await patchSeatShare(seat, { shared: true })
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: `${getSeatLabel(seat.seatNumber)} shared`,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-share-2'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Unable to share seat',
|
||||||
|
description: getErrorMessage(error, 'Please try again in a moment.'),
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-alert'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
seatActionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
||||||
if (seatActionId.value || shareSeatsLoading.value) {
|
if (seatActionId.value || shareSeatsLoading.value) {
|
||||||
return
|
return
|
||||||
@@ -329,6 +349,14 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
|||||||
seatActionId.value = null
|
seatActionId.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openBatchShare() {
|
||||||
|
const shared = await shareSeats()
|
||||||
|
|
||||||
|
if (shared) {
|
||||||
|
batchShareModalOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -341,13 +369,13 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto rounded-2xl border border-default bg-default p-2 shadow-sm">
|
<div class="rounded-2xl border border-default bg-default p-1.5 shadow-sm">
|
||||||
<div class="flex min-w-max gap-2 sm:min-w-0 sm:grid sm:grid-cols-3">
|
<div class="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex min-h-11 min-w-32 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition sm:min-w-0"
|
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
|
:class="activeTab === tab.id
|
||||||
? 'bg-primary text-inverted shadow-sm'
|
? 'bg-primary text-inverted shadow-sm'
|
||||||
: 'bg-elevated text-default hover:bg-muted'"
|
: 'bg-elevated text-default hover:bg-muted'"
|
||||||
@@ -362,134 +390,70 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
|||||||
<UCard
|
<UCard
|
||||||
v-if="activeTab === 'main'"
|
v-if="activeTab === 'main'"
|
||||||
class="border border-default bg-default shadow-sm"
|
class="border border-default bg-default shadow-sm"
|
||||||
:ui="{ body: 'space-y-5 p-4 sm:p-6' }"
|
:ui="{ body: 'p-5 sm:p-8' }"
|
||||||
>
|
>
|
||||||
<div class="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
<div class="mx-auto flex max-w-sm flex-col items-center gap-5 text-center">
|
||||||
<div class="space-y-4 rounded-2xl border border-default bg-elevated p-4 text-center">
|
<div class="rounded-[1.75rem] border border-default bg-elevated p-5 shadow-sm">
|
||||||
<QrCodeSvg :value="receipt.receiptUrl" :size="220" />
|
<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">
|
||||||
|
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">
|
||||||
|
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">
|
||||||
|
Price
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-highlighted">
|
||||||
|
{{ formatBookingCurrency(receipt.booking.totalPrice) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<UButton
|
<UButton
|
||||||
label="Share Receipt"
|
label="Share Seat"
|
||||||
icon="i-lucide-share-2"
|
icon="i-lucide-share-2"
|
||||||
class="justify-center"
|
class="w-full justify-center sm:w-auto"
|
||||||
:loading="receiptActionLoading"
|
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
|
||||||
@click="shareReceiptLink"
|
@click="batchShareModalOpen = true"
|
||||||
/>
|
/>
|
||||||
<UButton
|
|
||||||
:to="receipt.receiptUrl"
|
|
||||||
target="_blank"
|
|
||||||
label="Open Link"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
icon="i-lucide-external-link"
|
|
||||||
class="justify-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Status
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<UBadge :label="getBookingStatusLabel(receipt.booking.status)" :color="statusColor" variant="soft" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Shared
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-2xl font-bold text-highlighted">
|
|
||||||
{{ sharedSeats.length }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Available
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-2xl font-bold text-highlighted">
|
|
||||||
{{ availableSeats.length }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Total
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-2xl font-bold text-highlighted">
|
|
||||||
{{ totalFormatted }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-default sm:col-span-2 xl:col-span-4">
|
|
||||||
<div
|
|
||||||
v-for="row in detailRows"
|
|
||||||
: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 class="min-w-0 font-medium text-highlighted break-words">
|
|
||||||
{{ row.value }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<UCard
|
<UCard
|
||||||
v-else-if="activeTab === 'status'"
|
v-else-if="activeTab === 'status'"
|
||||||
class="border border-default bg-default shadow-sm"
|
class="border border-default bg-default shadow-sm"
|
||||||
:ui="{ body: 'space-y-4 p-4 sm:p-6' }"
|
:ui="{ body: 'p-0' }"
|
||||||
>
|
>
|
||||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="overflow-hidden rounded-2xl">
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Status
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<UBadge :label="getBookingStatusLabel(receipt.booking.status)" :color="statusColor" variant="soft" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Date
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 font-semibold text-highlighted">
|
|
||||||
{{ DINNER_EVENT_DATE_LABEL }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Time
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 font-semibold text-highlighted">
|
|
||||||
{{ DINNER_EVENT_TIME_LABEL }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
|
||||||
Venue
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 font-semibold text-highlighted">
|
|
||||||
{{ DINNER_EVENT_VENUE }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-default">
|
|
||||||
<div
|
<div
|
||||||
v-for="row in detailRows"
|
v-for="row in statusRows"
|
||||||
:key="row.label"
|
: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)]"
|
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">
|
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
|
||||||
{{ row.label }}
|
{{ row.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 font-medium text-highlighted break-words">
|
<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 }}
|
{{ row.value }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,12 +463,123 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
|||||||
<UCard
|
<UCard
|
||||||
v-else
|
v-else
|
||||||
class="border border-default bg-default shadow-sm"
|
class="border border-default bg-default shadow-sm"
|
||||||
:ui="{ body: 'space-y-5 p-4 sm:p-6' }"
|
:ui="{ header: 'px-4 py-3', body: 'p-0' }"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 xl:grid-cols-[20rem_minmax(0,1fr)]">
|
<template #header>
|
||||||
<div class="space-y-4 rounded-2xl border border-default bg-elevated p-4">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="rounded-2xl border border-default bg-default p-4">
|
<UBadge :label="`${receipt.seats.length} seats`" color="neutral" variant="soft" />
|
||||||
|
<UBadge :label="`${sharedSeats.length} shared`" color="primary" variant="soft" />
|
||||||
|
<UBadge :label="`${availableSeats.length} available`" color="neutral" variant="soft" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
label="Batch Share"
|
||||||
|
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="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) }}
|
||||||
|
</p>
|
||||||
|
<UBadge
|
||||||
|
:label="row.original.sharedAt ? 'Shared' : 'Available'"
|
||||||
|
:color="row.original.sharedAt ? 'primary' : 'neutral'"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{{ row.original.recipientName || 'Unassigned' }}
|
||||||
|
<span v-if="row.original.recipientPhone"> · {{ row.original.recipientPhone }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{{ row.original.sharedAt ? `Shared ${formatDateTime(row.original.sharedAt)}` : 'Ready to share' }}
|
||||||
|
</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="Open seat link"
|
||||||
|
class="min-w-10 justify-center px-2 sm:hidden"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:to="row.original.seatUrl"
|
||||||
|
target="_blank"
|
||||||
|
label="Open"
|
||||||
|
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 ? 'Unshare seat' : 'Share seat'"
|
||||||
|
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 ? 'Unshare' : '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="Batch Share Seats"
|
||||||
|
: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">
|
<p class="text-xs uppercase tracking-wide text-muted">
|
||||||
Seats To Share
|
Seats To Share
|
||||||
</p>
|
</p>
|
||||||
@@ -513,11 +588,12 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
|||||||
:min="1"
|
:min="1"
|
||||||
:max="maxShareCount"
|
:max="maxShareCount"
|
||||||
:disabled="!availableSeats.length || shareSeatsLoading"
|
:disabled="!availableSeats.length || shareSeatsLoading"
|
||||||
size="xl"
|
size="lg"
|
||||||
class="mt-3 w-full"
|
class="mt-3 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-default bg-default p-4">
|
|
||||||
|
<div class="rounded-2xl border border-default bg-elevated p-4">
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
<p class="text-xs uppercase tracking-wide text-muted">
|
||||||
Next Seats
|
Next Seats
|
||||||
</p>
|
</p>
|
||||||
@@ -544,101 +620,28 @@ async function unshareSeat(seat: PublicBookingSeatWithUrl) {
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<UButton
|
||||||
|
label="Cancel"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
class="justify-center"
|
||||||
|
:disabled="shareSeatsLoading"
|
||||||
|
@click="batchShareModalOpen = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
label="Share Seats"
|
label="Share Seats"
|
||||||
icon="i-lucide-share-2"
|
icon="i-lucide-share-2"
|
||||||
class="w-full justify-center"
|
class="justify-center"
|
||||||
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
|
:disabled="!availableSeats.length || Boolean(seatActionId)"
|
||||||
:loading="shareSeatsLoading"
|
:loading="shareSeatsLoading"
|
||||||
@click="shareSeats"
|
@click="openBatchShare"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="seat in receipt.seats"
|
|
||||||
:key="seat.id"
|
|
||||||
class="overflow-hidden rounded-2xl border border-default bg-elevated"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex min-h-11 w-full items-start justify-between gap-3 px-4 py-4 text-left"
|
|
||||||
@click="toggleSeatExpanded(seat.id)"
|
|
||||||
>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<p class="text-base font-semibold text-highlighted">
|
|
||||||
{{ getSeatLabel(seat.seatNumber) }}
|
|
||||||
</p>
|
|
||||||
<UBadge
|
|
||||||
:label="seat.sharedAt ? 'Shared' : 'Available'"
|
|
||||||
:color="seat.sharedAt ? 'primary' : 'neutral'"
|
|
||||||
variant="soft"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-muted">
|
|
||||||
{{ seat.recipientName || 'Unassigned' }}
|
|
||||||
<span v-if="seat.recipientPhone"> · {{ seat.recipientPhone }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UIcon
|
|
||||||
name="i-lucide-chevron-down"
|
|
||||||
class="mt-1 size-4 shrink-0 text-muted transition-transform"
|
|
||||||
:class="isSeatExpanded(seat.id) ? 'rotate-180' : ''"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isSeatExpanded(seat.id)"
|
|
||||||
class="border-t border-default bg-default px-4 py-4"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[12rem_minmax(0,1fr)]">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<QrCodeSvg :value="seat.seatUrl" :size="170" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4 text-sm text-default">
|
|
||||||
<p class="font-medium text-highlighted">
|
|
||||||
{{ seat.recipientName || 'Unassigned' }}
|
|
||||||
</p>
|
|
||||||
<p v-if="seat.recipientPhone" class="mt-1 text-muted">
|
|
||||||
{{ seat.recipientPhone }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-muted">
|
|
||||||
{{ seat.sharedAt ? `Shared ${formatDateTime(seat.sharedAt)}` : 'Available to share' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<UButton
|
|
||||||
:to="seat.seatUrl"
|
|
||||||
target="_blank"
|
|
||||||
label="Open Seat"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
icon="i-lucide-external-link"
|
|
||||||
class="flex-1 justify-center"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Unshare"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
icon="i-lucide-user-minus"
|
|
||||||
class="flex-1 justify-center"
|
|
||||||
:disabled="!seat.sharedAt || seatActionId === seat.id || shareSeatsLoading"
|
|
||||||
@click="unshareSeat(seat)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</UModal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user