Files
dticket.tootaio.com/app/pages/confirmation/[token].vue
xiaomai 227c64d346 refactor(ui): standardize page layouts and component styling
Introduce structural CSS classes for page shells, headers, and surface cards
Update primary theme color to red and neutral to zinc across the application
2026-05-08 16:25:42 +08:00

267 lines
8.1 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 { locale, t } = useLocale()
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, locale.value))
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
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.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
})
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 ? 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 (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'
}
)
booking.value = 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"
/>
<div class="overflow-hidden rounded-lg 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">
{{ t('common.totalPrice') }}
</div>
<div class="min-w-0 text-lg font-bold text-highlighted sm:text-xl">
{{ totalFormatted }}
</div>
</div>
</div>
<div class="action-row">
<UButton
to="/"
:label="t('confirm.backToForm')"
color="neutral"
variant="ghost"
class="justify-center"
/>
<UButton
v-if="booking.status === 'confirmed'"
:to="receiptPath"
:label="t('confirm.openReceipt')"
color="neutral"
variant="outline"
icon="i-lucide-receipt"
class="justify-center"
/>
<UButton
v-if="booking.status === 'pending'"
:label="t('confirm.confirmBooking')"
icon="i-lucide-check-check"
class="justify-center"
:loading="confirming"
@click="confirmBooking"
/>
<UButton
v-else
:label="t('confirm.cancelConfirmation')"
color="error"
variant="outline"
icon="i-lucide-x-circle"
class="justify-center"
:loading="cancelling"
@click="cancelBookingConfirmation"
/>
</div>
</UCard>
</div>
</UContainer>
</template>