Add external prop to UButton components linking to document URLs Prevents the router from intercepting external downloads
609 lines
19 KiB
Vue
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>
|