Secure API endpoints with requireBookingManager authorization check Update confirmation page to prompt for login if unauthorized Add safe redirect handling to login and guest middleware
608 lines
19 KiB
Vue
608 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"
|
|
: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>
|