Files
dticket.tootaio.com/app/pages/confirmation/[token].vue
xiaomai f6212d8101 fix(ui): mark transaction document links as external
Add external prop to UButton components linking to document URLs
Prevents the router from intercepting external downloads
2026-05-09 14:01:05 +08:00

609 lines
19 KiB
Vue

<script lang="ts" setup>
import type {
CancelBookingConfirmationResponse,
ConfirmBookingResponse,
PaymentMethod,
PublicBooking,
UpdateBookingDetailsResponse
} from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingStatusLabel
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
const route = useRoute()
const toast = useToast()
const apiClient = useApiClient()
const auth = useAuth()
const { locale, t } = useLocale()
const token = String(route.params.token || '')
const confirming = ref(false)
const cancelling = ref(false)
const savingPayment = ref(false)
const uploadingTransactionDocument = ref(false)
const deletingTransactionDocument = ref(false)
await auth.fetchSession()
let initialBooking: PublicBooking
try {
const response = await apiClient<{ booking: PublicBooking }>(`/api/public/bookings/${token}`)
initialBooking = response.booking
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.data?.statusCode || 404,
statusMessage: error?.data?.statusMessage || error?.message || 'Booking not found'
})
}
const booking = ref(initialBooking)
const paymentForm = reactive({
paymentMethod: initialBooking.paymentMethod
})
const transactionDocumentFile = ref<File | null>(null)
const transactionDocumentInputKey = ref(0)
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
const ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice, locale.value))
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
const signInPath = computed(() => `/login?redirect=${encodeURIComponent(route.fullPath)}`)
const canManageBooking = computed(() => {
const user = auth.user.value
return Boolean(user && (user.role === 'super_admin' || user.id === booking.value.personInChargeId))
})
const transactionDocumentLimit = 10 * 1024 * 1024
const transactionDocumentAccept = '.pdf,.jpg,.jpeg,.png,.webp,.heic,.heif,application/pdf,image/jpeg,image/png,image/webp,image/heic,image/heif'
const selectedTransactionDocumentName = computed(() => transactionDocumentFile.value?.name || '')
const paymentMethodItems = computed<{ label: string, value: PaymentMethod }[]>(() => [
{
label: t('confirm.paymentCash'),
value: 'cash'
},
{
label: t('confirm.paymentBank'),
value: 'bank'
}
])
watch(
() => paymentForm.paymentMethod,
(paymentMethod) => {
if (paymentMethod === 'cash') {
transactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
}
}
)
useSeoMeta({
title: () => `${t('confirm.title')} - ${booking.value.customerName}`,
description: () => t('confirm.description'),
ogTitle: () => `${t('confirm.title')} - ${booking.value.customerName}`,
ogDescription: () => t('confirm.description'),
robots: 'noindex,nofollow'
})
const detailRows = computed(() => {
const rows = [
{
label: t('confirm.guestOrganizer'),
value: booking.value.customerName
},
{
label: t('confirm.contactNumber'),
value: booking.value.customerPhone
},
{
label: t('confirm.pic'),
value: booking.value.personInChargeName
},
{
label: t('confirm.picPhone'),
value: booking.value.personInChargePhoneNumber
},
{
label: t('confirm.ticketCategory'),
value: ticketLabel.value
},
{
label: t('confirm.seatsCovered'),
value: String(booking.value.seatCount)
},
{
label: t('confirm.payment'),
value: booking.value.paymentMethod === 'bank' ? t('confirm.paymentBank') : t('confirm.paymentCash')
},
{
label: t('confirm.submittedLabel'),
value: formatDateTime(booking.value.createdAt, t('date.notAvailable'), locale.value)
}
]
if (booking.value.confirmedAt) {
rows.push({
label: t('confirm.confirmedAt'),
value: formatDateTime(booking.value.confirmedAt, t('date.notAvailable'), locale.value)
})
}
return rows
})
const isTableBooking = computed(() => {
return booking.value.bookingMode.toLowerCase().includes('table')
|| booking.value.bookingModeLabel.toLowerCase().includes('table')
})
function getDetailValueClass(row: { label: string }) {
return [
'min-w-0 font-medium text-highlighted break-words',
row.label === t('confirm.guestOrganizer') && isTableBooking.value ? 'max-h-16 overflow-hidden' : ''
]
}
function formatFileSize(size: number) {
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
return `${Math.max(Math.round(size / 1024), 1)} KB`
}
function onTransactionDocumentChange(event: Event) {
if (!canManageBooking.value) {
return
}
const input = event.target as HTMLInputElement
transactionDocumentFile.value = input.files?.[0] ?? null
}
function applyBooking(nextBooking: PublicBooking) {
booking.value = nextBooking
paymentForm.paymentMethod = nextBooking.paymentMethod
}
async function savePaymentDetails() {
if (!canManageBooking.value || savingPayment.value || booking.value.status !== 'pending') {
return booking.value
}
savingPayment.value = true
try {
const response = await apiClient<UpdateBookingDetailsResponse>(
`/api/public/bookings/${token}/payment`,
{
method: 'PATCH',
body: {
paymentMethod: paymentForm.paymentMethod
}
}
)
applyBooking(response.booking)
return response.booking
} finally {
savingPayment.value = false
}
}
async function uploadTransactionDocument(options: { silent?: boolean } = {}) {
if (!canManageBooking.value || !transactionDocumentFile.value || uploadingTransactionDocument.value || booking.value.status !== 'pending') {
return booking.value
}
if (transactionDocumentFile.value.size > transactionDocumentLimit) {
toast.add({
title: t('confirm.uploadFailed'),
description: t('confirm.documentSizeInvalid'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
return booking.value
}
uploadingTransactionDocument.value = true
try {
paymentForm.paymentMethod = 'bank'
await savePaymentDetails()
const formData = new FormData()
formData.append('document', transactionDocumentFile.value)
const response = await apiClient<UpdateBookingDetailsResponse>(
`/api/public/bookings/${token}/transaction-document`,
{
method: 'POST',
body: formData
}
)
applyBooking(response.booking)
transactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
if (!options.silent) {
toast.add({
title: t('confirm.documentUploaded'),
description: t('confirm.documentUploadedDescription'),
color: 'success',
icon: 'i-lucide-file-check'
})
}
return response.booking
} catch (error) {
if (options.silent) {
throw error
}
toast.add({
title: t('confirm.uploadFailed'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
return booking.value
} finally {
uploadingTransactionDocument.value = false
}
}
async function deleteTransactionDocument() {
if (!canManageBooking.value || !booking.value.transactionDocument || deletingTransactionDocument.value || booking.value.status !== 'pending') {
return
}
if (import.meta.client && !window.confirm(t('confirm.deleteDocumentPrompt'))) {
return
}
deletingTransactionDocument.value = true
try {
const response = await apiClient<UpdateBookingDetailsResponse>(
`/api/public/bookings/${token}/transaction-document`,
{
method: 'DELETE'
}
)
applyBooking(response.booking)
transactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
toast.add({
title: t('confirm.documentDeleted'),
description: t('confirm.documentDeletedDescription'),
color: 'success',
icon: 'i-lucide-trash-2'
})
} catch (error) {
toast.add({
title: t('confirm.deleteFailed'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
deletingTransactionDocument.value = false
}
}
async function confirmBooking() {
if (!canManageBooking.value || booking.value.status === 'confirmed') {
return
}
confirming.value = true
try {
await savePaymentDetails()
if (paymentForm.paymentMethod === 'bank') {
await uploadTransactionDocument({ silent: true })
}
const response = await apiClient<ConfirmBookingResponse>(
`/api/public/bookings/${token}/confirm`,
{
method: 'POST'
}
)
applyBooking(response.booking)
toast.add({
title: response.alreadyConfirmed ? t('confirm.alreadyConfirmedToast') : t('confirm.confirmedToast'),
description: response.alreadyConfirmed
? t('confirm.alreadyConfirmedDescription')
: response.ticketReceiptWhatsApp.sent
? t('confirm.receiptSent', { phone: response.ticketReceiptWhatsApp.recipientPhone })
: t('confirm.receiptNotSent', { error: response.ticketReceiptWhatsApp.error }),
color: response.alreadyConfirmed || response.ticketReceiptWhatsApp.sent ? 'success' : 'warning',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {
toast.add({
title: t('confirm.failed'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
confirming.value = false
}
}
async function cancelBookingConfirmation() {
if (!canManageBooking.value || booking.value.status !== 'confirmed') {
return
}
if (import.meta.client && !window.confirm(t('confirm.cancelPrompt'))) {
return
}
cancelling.value = true
try {
const response = await apiClient<CancelBookingConfirmationResponse>(
`/api/public/bookings/${token}/cancel`,
{
method: 'POST'
}
)
applyBooking(response.booking)
toast.add({
title: response.alreadyPending ? t('confirm.alreadyPending') : t('confirm.cancelled'),
description: response.alreadyPending
? t('confirm.alreadyPendingDescription')
: t('confirm.cancelledDescription'),
color: response.alreadyPending ? 'warning' : 'success',
icon: 'i-lucide-x-circle'
})
} catch (error) {
toast.add({
title: t('confirm.cancelFailed'),
description: getErrorMessage(error, t('booking.tryAgain')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
cancelling.value = false
}
}
</script>
<template>
<UContainer class="page-shell-compact">
<div class="space-y-5">
<div class="page-header text-center sm:items-center">
<UBadge :label="t('confirm.badge')" color="primary" variant="soft" class="page-eyebrow" />
<h1 class="page-title">
{{ t('confirm.title') }}
</h1>
<p class="page-description">
{{ t('confirm.description') }}
</p>
</div>
<UCard
class="surface-card overflow-hidden rounded-lg"
:ui="{ body: 'space-y-4 p-4 sm:p-5' }"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted">
{{ t('confirm.status') }}
</p>
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel, locale)" :color="statusColor" variant="soft" />
</div>
<div class="text-sm text-muted">
{{ t('confirm.submitted', { date: formatDateTime(booking.createdAt, t('date.notAvailable'), locale) }) }}
</div>
</div>
<UAlert
v-if="booking.status === 'confirmed'"
:title="t('confirm.alreadyConfirmed')"
:description="t('confirm.confirmedOn', { date: formatDateTime(booking.confirmedAt, t('date.notAvailable'), locale) })"
color="success"
icon="i-lucide-badge-check"
/>
<UAlert
v-if="!canManageBooking"
:title="auth.user.value ? t('confirm.managementRestricted') : t('confirm.signInToManage')"
color="warning"
icon="i-lucide-lock-keyhole"
/>
<div class="overflow-hidden rounded-lg border border-default">
<div
v-for="row in detailRows"
:key="row.label"
class="grid gap-1 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-3"
>
<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="getDetailValueClass(row)">
{{ row.value }}
</div>
</div>
<div class="grid gap-1 bg-primary/5 px-4 py-3 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-3">
<div class="text-xs font-semibold uppercase tracking-wide text-primary sm:text-sm sm:normal-case sm:tracking-normal">
{{ t('common.totalPrice') }}
</div>
<div class="min-w-0 text-lg font-bold text-highlighted sm:text-xl">
{{ totalFormatted }}
</div>
</div>
</div>
<div v-if="canManageBooking" class="space-y-3 rounded-lg border border-default p-4">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-sm font-semibold text-highlighted">
{{ t('confirm.payment') }}
</h2>
<p class="text-xs text-muted">
{{ booking.status === 'pending' ? t('confirm.paymentPendingDescription') : t('confirm.paymentConfirmedDescription') }}
</p>
</div>
<UBadge :label="paymentForm.paymentMethod === 'bank' ? t('confirm.paymentBank') : t('confirm.paymentCash')" :color="paymentForm.paymentMethod === 'bank' ? 'info' : 'neutral'" variant="soft" />
</div>
<URadioGroup
v-model="paymentForm.paymentMethod"
orientation="horizontal"
variant="card"
indicator="hidden"
:items="paymentMethodItems"
:disabled="booking.status !== 'pending' || confirming || savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
:ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}"
/>
<div v-if="paymentForm.paymentMethod === 'bank'" class="space-y-3">
<div
v-if="booking.transactionDocument"
class="surface-panel flex flex-col gap-2 rounded-lg px-3 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-highlighted">
{{ booking.transactionDocument.originalName }}
</p>
<p class="text-xs text-muted">
{{ formatFileSize(booking.transactionDocument.size) }} - {{ formatDateTime(booking.transactionDocument.uploadedAt, t('date.notAvailable'), locale) }}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<UButton
:to="booking.transactionDocument.url"
external
:label="t('confirm.download')"
color="neutral"
variant="outline"
icon="i-lucide-file-down"
size="sm"
class="justify-center"
:disabled="confirming || deletingTransactionDocument"
/>
<UButton
v-if="booking.status === 'pending'"
:label="t('confirm.delete')"
color="error"
variant="outline"
icon="i-lucide-trash-2"
size="sm"
class="justify-center"
:loading="deletingTransactionDocument"
:disabled="confirming || uploadingTransactionDocument"
@click="deleteTransactionDocument"
/>
</div>
</div>
<UFormField v-if="booking.status === 'pending'" name="transactionDocument" :label="t('confirm.transactionDocument')">
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<UInput
:key="transactionDocumentInputKey"
type="file"
:accept="transactionDocumentAccept"
:disabled="confirming || uploadingTransactionDocument || deletingTransactionDocument"
size="lg"
class="w-full"
@change="onTransactionDocumentChange"
/>
<UButton
:label="t('confirm.upload')"
icon="i-lucide-upload"
class="justify-center"
:loading="uploadingTransactionDocument"
:disabled="!transactionDocumentFile || confirming || deletingTransactionDocument"
@click="uploadTransactionDocument()"
/>
</div>
<template #help>
{{ selectedTransactionDocumentName || t('confirm.documentHelp') }}
</template>
</UFormField>
</div>
</div>
<div class="action-row">
<UButton
to="/"
:label="t('confirm.backToForm')"
color="neutral"
variant="ghost"
class="justify-center"
/>
<UButton
v-if="canManageBooking && booking.status === 'confirmed'"
:to="receiptPath"
:label="t('confirm.openReceipt')"
color="neutral"
variant="outline"
icon="i-lucide-receipt"
class="justify-center"
/>
<UButton
v-if="canManageBooking && booking.status === 'pending'"
:label="t('confirm.confirmBooking')"
icon="i-lucide-check-check"
class="justify-center"
:loading="confirming"
:disabled="savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
@click="confirmBooking"
/>
<UButton
v-else-if="canManageBooking"
:label="t('confirm.cancelConfirmation')"
color="error"
variant="outline"
icon="i-lucide-x-circle"
class="justify-center"
:loading="cancelling"
:disabled="savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
@click="cancelBookingConfirmation"
/>
<UButton
v-else-if="!auth.user.value"
:to="signInPath"
:label="t('confirm.signInToManage')"
color="primary"
variant="outline"
icon="i-lucide-log-in"
class="justify-center"
/>
</div>
</UCard>
</div>
</UContainer>
</template>