Files
dticket.tootaio.com/app/pages/confirmation/[token].vue
xiaomai 13e85cfcd0 feat(bookings): allow cancelling booking confirmations
Add API endpoint to revert confirmed bookings to pending status
Add unconfirm buttons to the bookings list and confirmation page
Update inventory summary when a confirmation is cancelled
2026-05-05 07:04:42 +08:00

266 lines
8.0 KiB
Vue

<script lang="ts" setup>
import type { CancelBookingConfirmationResponse, ConfirmBookingResponse, PublicBooking } 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 token = String(route.params.token || '')
const confirming = ref(false)
const cancelling = ref(false)
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 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))
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
const detailRows = computed(() => {
const rows = [
{
label: 'Guest / Organizer',
value: booking.value.customerName
},
{
label: 'Contact Number',
value: booking.value.customerPhone
},
{
label: 'Person In Charge',
value: booking.value.personInChargeName
},
{
label: 'PIC Phone',
value: booking.value.personInChargePhoneNumber
},
{
label: 'Ticket Category',
value: ticketLabel.value
},
{
label: 'Seats Covered',
value: String(booking.value.seatCount)
},
{
label: 'Submitted',
value: formatDateTime(booking.value.createdAt)
}
]
if (booking.value.confirmedAt) {
rows.push({
label: 'Confirmed At',
value: formatDateTime(booking.value.confirmedAt)
})
}
return rows
})
async function confirmBooking() {
if (booking.value.status === 'confirmed') {
return
}
confirming.value = true
try {
const response = await apiClient<ConfirmBookingResponse>(
`/api/public/bookings/${token}/confirm`,
{
method: 'POST'
}
)
booking.value = response.booking
toast.add({
title: response.alreadyConfirmed ? 'Booking already confirmed' : 'Booking confirmed',
description: response.alreadyConfirmed
? 'This booking had already been confirmed earlier.'
: response.ticketReceiptWhatsApp.sent
? `Ticket receipt was sent to ${response.ticketReceiptWhatsApp.recipientPhone}.`
: `Booking confirmed, but the ticket receipt WhatsApp was not sent: ${response.ticketReceiptWhatsApp.error}`,
color: response.alreadyConfirmed || response.ticketReceiptWhatsApp.sent ? 'success' : 'warning',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {
toast.add({
title: 'Confirmation failed',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
confirming.value = false
}
}
async function cancelBookingConfirmation() {
if (booking.value.status !== 'confirmed') {
return
}
if (import.meta.client && !window.confirm('Cancel this confirmation? The booking will return to pending and the seats will be released.')) {
return
}
cancelling.value = true
try {
const response = await apiClient<CancelBookingConfirmationResponse>(
`/api/public/bookings/${token}/cancel`,
{
method: 'POST'
}
)
booking.value = response.booking
toast.add({
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
description: response.alreadyPending
? 'This booking was already pending confirmation.'
: 'The booking has been returned to pending status.',
color: response.alreadyPending ? 'warning' : 'success',
icon: 'i-lucide-x-circle'
})
} catch (error) {
toast.add({
title: 'Cancellation failed',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
cancelling.value = false
}
}
</script>
<template>
<UContainer class="py-8">
<div class="mx-auto max-w-3xl space-y-5">
<div class="space-y-2 text-center">
<UBadge label="PIC Confirmation" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
Review Booking Details
</h1>
<p class="text-sm text-muted">
Confirm the booking after verifying the details below.
</p>
</div>
<UCard
class="border border-default bg-default shadow-sm"
: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">
Booking status
</p>
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel)" :color="statusColor" variant="soft" />
</div>
<div class="text-sm text-muted">
Submitted {{ formatDateTime(booking.createdAt) }}
</div>
</div>
<UAlert
v-if="booking.status === 'confirmed'"
title="Booking already confirmed"
:description="`Confirmed on ${formatDateTime(booking.confirmedAt)}.`"
color="success"
icon="i-lucide-badge-check"
/>
<div class="overflow-hidden rounded-xl border border-default">
<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)]"
>
<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 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="text-xs font-semibold uppercase tracking-wide text-primary sm:text-sm sm:normal-case sm:tracking-normal">
Total Price
</div>
<div class="min-w-0 text-lg font-bold text-highlighted sm:text-xl">
{{ totalFormatted }}
</div>
</div>
</div>
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<UButton
to="/"
label="Back To Booking Form"
color="neutral"
variant="ghost"
class="justify-center"
/>
<UButton
v-if="booking.status === 'confirmed'"
:to="receiptPath"
label="Open Ticket Receipt"
color="neutral"
variant="outline"
icon="i-lucide-receipt"
class="justify-center"
/>
<UButton
v-if="booking.status === 'pending'"
label="Confirm This Booking"
icon="i-lucide-check-check"
class="justify-center"
:loading="confirming"
@click="confirmBooking"
/>
<UButton
v-else
label="Cancel Confirmation"
color="error"
variant="outline"
icon="i-lucide-x-circle"
class="justify-center"
:loading="cancelling"
@click="cancelBookingConfirmation"
/>
</div>
</UCard>
</div>
</UContainer>
</template>