feat(bookings): add payment and document upload to confirmation page

Allow users to select payment method and upload receipts before confirming.
Add public API endpoints for payment updates and document management.
This commit is contained in:
2026-05-09 13:15:45 +08:00
parent b64a2b4c1c
commit a56a6706b0
11 changed files with 746 additions and 18 deletions

View File

@@ -1,5 +1,11 @@
<script lang="ts" setup>
import type { CancelBookingConfirmationResponse, ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
import type {
CancelBookingConfirmationResponse,
ConfirmBookingResponse,
PaymentMethod,
PublicBooking,
UpdateBookingDetailsResponse
} from '~~/shared/booking'
import {
formatBookingCurrency,
@@ -17,6 +23,9 @@ 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)
let initialBooking: PublicBooking
@@ -31,11 +40,39 @@ try {
}
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 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}`,
@@ -71,6 +108,10 @@ const detailRows = computed(() => {
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)
@@ -87,6 +128,167 @@ const detailRows = computed(() => {
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) {
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 (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 (!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 (!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 (booking.value.status === 'confirmed') {
return
@@ -95,6 +297,11 @@ async function confirmBooking() {
confirming.value = true
try {
await savePaymentDetails()
if (paymentForm.paymentMethod === 'bank') {
await uploadTransactionDocument({ silent: true })
}
const response = await apiClient<ConfirmBookingResponse>(
`/api/public/bookings/${token}/confirm`,
{
@@ -102,7 +309,7 @@ async function confirmBooking() {
}
)
booking.value = response.booking
applyBooking(response.booking)
toast.add({
title: response.alreadyConfirmed ? t('confirm.alreadyConfirmedToast') : t('confirm.confirmedToast'),
@@ -145,7 +352,7 @@ async function cancelBookingConfirmation() {
}
)
booking.value = response.booking
applyBooking(response.booking)
toast.add({
title: response.alreadyPending ? t('confirm.alreadyPending') : t('confirm.cancelled'),
@@ -210,17 +417,17 @@ async function cancelBookingConfirmation() {
<div
v-for="row in detailRows"
:key="row.label"
class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[11rem_minmax(0,1fr)]"
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="min-w-0 font-medium text-highlighted break-words">
<div :class="getDetailValueClass(row)">
{{ row.value }}
</div>
</div>
<div class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 bg-primary/5 px-4 py-3 sm:grid-cols-[11rem_minmax(0,1fr)]">
<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>
@@ -230,6 +437,99 @@ async function cancelBookingConfirmation() {
</div>
</div>
<div 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="/"
@@ -255,6 +555,7 @@ async function cancelBookingConfirmation() {
icon="i-lucide-check-check"
class="justify-center"
:loading="confirming"
:disabled="savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
@click="confirmBooking"
/>
@@ -266,6 +567,7 @@ async function cancelBookingConfirmation() {
icon="i-lucide-x-circle"
class="justify-center"
:loading="cancelling"
:disabled="savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
@click="cancelBookingConfirmation"
/>
</div>