Implement useLocale composable and shared translation dictionaries Translate public pages, booking flow, and receipt views Store booking locale to send localized WhatsApp notifications
267 lines
8.2 KiB
Vue
267 lines
8.2 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="py-8">
|
|
<div class="mx-auto max-w-3xl space-y-5">
|
|
<div class="space-y-2 text-center">
|
|
<UBadge :label="t('confirm.badge')" color="primary" variant="soft" class="rounded-full" />
|
|
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
|
|
{{ t('confirm.title') }}
|
|
</h1>
|
|
<p class="text-sm text-muted">
|
|
{{ t('confirm.description') }}
|
|
</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">
|
|
{{ 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-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">
|
|
{{ t('common.totalPrice') }}
|
|
</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="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>
|