Configure default head meta and title template in nuxt.config.ts Add dynamic SEO meta tags and robots directives to all pages
381 lines
12 KiB
Vue
381 lines
12 KiB
Vue
<script lang="ts" setup>
|
||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||
|
||
import {
|
||
DEFAULT_PHONE_COUNTRY_CODE,
|
||
isValidPhoneNumber,
|
||
normalizePhoneNumber,
|
||
type PublicContact
|
||
} from '~~/shared/auth'
|
||
import type { BookingModeOption, CreateBookingResponse, PublicBookingConfig, TicketCatalogItem } from '~~/shared/booking'
|
||
import {
|
||
formatBookingCurrency,
|
||
getSeatCount,
|
||
type BookingMode,
|
||
type TicketType
|
||
} from '~~/shared/booking'
|
||
|
||
import { getErrorMessage } from '../utils/errors'
|
||
|
||
|
||
const toast = useToast()
|
||
const apiClient = useApiClient()
|
||
const { locale, t } = useLocale()
|
||
|
||
const [bookingConfig, contactsResponse] = await Promise.all([
|
||
apiClient<PublicBookingConfig>('/api/public/booking-config'),
|
||
apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||
])
|
||
|
||
const seoDescription = computed(() => {
|
||
return `${bookingConfig.event.dateLabel} · ${bookingConfig.event.timeLabel} · ${bookingConfig.event.venue}`
|
||
})
|
||
|
||
useSeoMeta({
|
||
title: () => bookingConfig.event.title,
|
||
description: () => seoDescription.value,
|
||
ogTitle: () => bookingConfig.event.title,
|
||
ogDescription: () => seoDescription.value,
|
||
twitterTitle: () => bookingConfig.event.title,
|
||
twitterDescription: () => seoDescription.value,
|
||
robots: 'index,follow'
|
||
})
|
||
|
||
const eventDetails = computed(() => [
|
||
{
|
||
label: t('common.date'),
|
||
value: bookingConfig.event.dateLabel,
|
||
icon: 'lucide:calendar-days'
|
||
},
|
||
{
|
||
label: t('common.time'),
|
||
value: bookingConfig.event.timeLabel,
|
||
icon: 'lucide:clock-6'
|
||
},
|
||
{
|
||
label: t('common.venue'),
|
||
value: bookingConfig.event.venue,
|
||
icon: 'lucide:map-pin'
|
||
}
|
||
])
|
||
|
||
const bookingModeOptions = computed(() => {
|
||
return bookingConfig.bookingModes.map((mode) => ({
|
||
value: mode.value,
|
||
label: locale.value === 'zh' ? translateBookingModeLabel(mode.label) : mode.label
|
||
}))
|
||
})
|
||
|
||
const ticketCatalogOptions = computed(() => {
|
||
return bookingConfig.ticketCatalog.map((ticket) => ({
|
||
value: ticket.value,
|
||
label: locale.value === 'zh' ? translateTicketLabel(ticket.label) : ticket.label,
|
||
description: locale.value === 'zh' ? translateTicketDescription(ticket.description) : ticket.description
|
||
}))
|
||
})
|
||
|
||
const personInCharge = computed(() => {
|
||
return contactsResponse.contacts.map((contact) => ({
|
||
label: contact.fullName,
|
||
value: contact.id
|
||
}))
|
||
})
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
phone: DEFAULT_PHONE_COUNTRY_CODE,
|
||
bookingMode: (bookingConfig.bookingModes[0]?.value ?? '') as BookingMode,
|
||
quantity: 1,
|
||
ticketType: (bookingConfig.ticketCatalog[0]?.value ?? '') as TicketType
|
||
})
|
||
|
||
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
|
||
|
||
const selectedPersonInChargeRecord = computed(() => {
|
||
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
|
||
})
|
||
|
||
const selectedBookingMode = computed<BookingModeOption | null>(() => {
|
||
return bookingConfig.bookingModes.find((mode) => mode.value === form.bookingMode) ?? bookingConfig.bookingModes[0] ?? null
|
||
})
|
||
|
||
const selectedTicket = computed<TicketCatalogItem | null>(() => {
|
||
return bookingConfig.ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? bookingConfig.ticketCatalog[0] ?? null
|
||
})
|
||
|
||
const submittingBooking = ref(false)
|
||
|
||
const quantityLabel = computed(() => {
|
||
if (selectedBookingMode.value?.quantityLabel) {
|
||
return locale.value === 'zh'
|
||
? translateBookingModeQuantityLabel(selectedBookingMode.value.quantityLabel)
|
||
: selectedBookingMode.value.quantityLabel
|
||
}
|
||
|
||
return t('booking.quantity')
|
||
})
|
||
|
||
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
|
||
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
|
||
|
||
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value, locale.value))
|
||
|
||
function translateBookingModeLabel(label: string) {
|
||
const normalized = label.toLowerCase()
|
||
|
||
if (normalized.includes('table')) {
|
||
return '桌席(10 个座位)'
|
||
}
|
||
|
||
if (normalized.includes('seat')) {
|
||
return '座位'
|
||
}
|
||
|
||
return label
|
||
}
|
||
|
||
function translateBookingModeQuantityLabel(label: string) {
|
||
const normalized = label.toLowerCase()
|
||
|
||
if (normalized.includes('table')) {
|
||
return '桌数'
|
||
}
|
||
|
||
if (normalized.includes('seat')) {
|
||
return '座位数量'
|
||
}
|
||
|
||
return label
|
||
}
|
||
|
||
function translateTicketLabel(label: string) {
|
||
const normalized = label.toLowerCase()
|
||
|
||
if (normalized.includes('supporter')) {
|
||
return '支持者'
|
||
}
|
||
|
||
return label
|
||
}
|
||
|
||
function translateTicketDescription(description: string) {
|
||
return description.replace(/seat/gi, locale.value === 'zh' ? '座位' : 'seat')
|
||
}
|
||
|
||
function validateBooking(state: typeof form): FormError[] {
|
||
const errors: FormError[] = []
|
||
|
||
if (!state.name.trim()) {
|
||
errors.push({ name: 'name', message: t('booking.nameRequired') })
|
||
}
|
||
|
||
if (!state.phone.trim()) {
|
||
errors.push({ name: 'phone', message: t('booking.phoneRequired') })
|
||
} else if (!isValidPhoneNumber(state.phone.trim())) {
|
||
errors.push({ name: 'phone', message: t('booking.phoneInvalid') })
|
||
}
|
||
|
||
if (state.quantity < 1) {
|
||
errors.push({ name: 'quantity', message: t('booking.quantityMin', { label: quantityLabel.value }) })
|
||
}
|
||
|
||
if (!selectedBookingMode.value) {
|
||
errors.push({ name: 'bookingMode', message: t('booking.modeRequired') })
|
||
}
|
||
|
||
if (!selectedTicket.value) {
|
||
errors.push({ name: 'ticketType', message: t('booking.ticketRequired') })
|
||
}
|
||
|
||
return errors
|
||
}
|
||
|
||
async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||
const selectedPic = selectedPersonInChargeRecord.value
|
||
|
||
if (!selectedPic) {
|
||
toast.add({
|
||
title: t('booking.noPicTitle'),
|
||
description: t('booking.noPicDescription'),
|
||
color: 'error',
|
||
icon: 'i-lucide-circle-alert'
|
||
})
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
|
||
submittingBooking.value = true
|
||
|
||
try {
|
||
const response = await apiClient<CreateBookingResponse>('/api/public/bookings', {
|
||
method: 'POST',
|
||
body: {
|
||
customerName: form.name.trim(),
|
||
customerPhone: normalizePhoneNumber(form.phone),
|
||
bookingMode: form.bookingMode,
|
||
quantity: form.quantity,
|
||
ticketType: form.ticketType,
|
||
personInChargeId: selectedPic.id,
|
||
locale: locale.value
|
||
}
|
||
})
|
||
|
||
const bookingWindow = window.open(response.whatsappUrl, '_blank', 'noopener,noreferrer')
|
||
|
||
if (!bookingWindow) {
|
||
window.location.assign(response.whatsappUrl)
|
||
return
|
||
}
|
||
|
||
toast.add({
|
||
title: t('booking.whatsappOpened'),
|
||
description: t('booking.whatsappOpenedDescription', { name: selectedPic.fullName }),
|
||
color: 'success',
|
||
icon: 'i-lucide-check-circle-2'
|
||
})
|
||
} catch (error) {
|
||
toast.add({
|
||
title: t('booking.createFailed'),
|
||
description: getErrorMessage(error, t('booking.tryAgain')),
|
||
color: 'error',
|
||
icon: 'i-lucide-circle-alert'
|
||
})
|
||
} finally {
|
||
submittingBooking.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<UContainer class="page-shell-narrow">
|
||
<div class="grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)] xl:items-start xl:gap-8">
|
||
<UCard
|
||
class="surface-card overflow-hidden rounded-lg xl:sticky xl:top-6"
|
||
:ui="{ header: 'px-4 py-4 sm:px-5 sm:py-5', body: 'space-y-4 px-4 pb-4 pt-0 sm:px-5 sm:pb-5' }"
|
||
>
|
||
<template #header>
|
||
<div class="space-y-1">
|
||
<UBadge :label="t('layout.brand')" color="primary" variant="soft" class="page-eyebrow" />
|
||
<h1 class="text-2xl font-bold leading-tight text-highlighted sm:text-3xl xl:text-2xl">
|
||
{{ bookingConfig.event.title }}
|
||
</h1>
|
||
<p class="page-description">
|
||
{{ t('booking.seatGeneration', { count: seatCount, seatLabel: locale === 'zh' ? '座位' : `seat${seatCount === 1 ? '' : 's'}` }) }}
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="grid gap-3">
|
||
<div
|
||
v-for="detail in eventDetails"
|
||
:key="detail.label"
|
||
class="surface-panel rounded-lg p-4"
|
||
>
|
||
<div class="flex items-start gap-3">
|
||
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||
<UIcon :name="detail.icon" class="size-5" />
|
||
</div>
|
||
<div class="min-w-0">
|
||
<p class="text-xs font-semibold uppercase text-muted">
|
||
{{ detail.label }}
|
||
</p>
|
||
<p class="mt-1 text-sm font-semibold leading-6 text-highlighted break-words">
|
||
{{ detail.value }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</UCard>
|
||
|
||
<UCard
|
||
id="booking-form"
|
||
class="surface-card overflow-hidden rounded-lg"
|
||
:ui="{ body: 'space-y-5 p-4 sm:space-y-6 sm:p-6' }"
|
||
>
|
||
<div class="space-y-1">
|
||
<p class="text-sm font-semibold text-primary">
|
||
{{ t('booking.bookNow') }}
|
||
</p>
|
||
<h2 class="text-xl font-semibold text-highlighted">
|
||
{{ t('booking.ticketCategory') }}
|
||
</h2>
|
||
</div>
|
||
|
||
<UForm :state="form" :validate="validateBooking" class="space-y-5 sm:space-y-6" @submit="bookTicket">
|
||
<UFormField name="name" :label="t('booking.name')" required>
|
||
<UInput v-model="form.name" size="xl" class="w-full" :placeholder="t('booking.namePlaceholder')" />
|
||
</UFormField>
|
||
|
||
<UFormField name="phone" :label="t('common.phoneNumber')" required>
|
||
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" :placeholder="t('booking.phonePlaceholder')" />
|
||
</UFormField>
|
||
|
||
<UFormField :label="t('booking.bookingMode')" name="bookingMode">
|
||
<URadioGroup
|
||
v-model="form.bookingMode"
|
||
orientation="horizontal"
|
||
variant="card"
|
||
indicator="hidden"
|
||
:items="bookingModeOptions"
|
||
:ui="{
|
||
fieldset: 'grid grid-cols-2 gap-3',
|
||
item: 'min-h-14 rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||
}"
|
||
/>
|
||
</UFormField>
|
||
|
||
<UFormField :label="quantityLabel" name="quantity">
|
||
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
|
||
<template #help>
|
||
{{ t('booking.seatGeneration', { count: seatCount, seatLabel: locale === 'zh' ? '座位' : `seat${seatCount === 1 ? '' : 's'}` }) }}
|
||
</template>
|
||
</UFormField>
|
||
|
||
<UFormField :label="t('booking.personInCharge')">
|
||
<USelect
|
||
v-model="selectedPersonInCharge"
|
||
size="xl"
|
||
class="w-full"
|
||
:items="personInCharge"
|
||
:disabled="!personInCharge.length"
|
||
/>
|
||
</UFormField>
|
||
|
||
<UFormField :label="t('booking.ticketCategory')" name="ticketType">
|
||
<URadioGroup
|
||
v-model="form.ticketType"
|
||
orientation="horizontal"
|
||
variant="card"
|
||
indicator="hidden"
|
||
:items="ticketCatalogOptions"
|
||
:ui="{
|
||
fieldset: 'grid grid-cols-2 gap-3',
|
||
item: 'min-h-14 rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||
}"
|
||
/>
|
||
</UFormField>
|
||
|
||
<div class="surface-panel rounded-lg px-4 py-4">
|
||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||
<span class="text-sm font-medium text-muted">{{ t('common.totalPrice') }}</span>
|
||
<span class="text-2xl font-bold tabular-nums text-highlighted">{{ totalFormatted }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<UButton
|
||
id="getTicketBtn"
|
||
type="submit"
|
||
:label="t('booking.bookNow')"
|
||
size="xl"
|
||
class="min-h-12 w-full justify-center"
|
||
:disabled="!selectedPersonInCharge || !selectedBookingMode || !selectedTicket"
|
||
:loading="submittingBooking"
|
||
/>
|
||
</UForm>
|
||
</UCard>
|
||
</div>
|
||
</UContainer>
|
||
</template>
|