Files
dticket.tootaio.com/app/pages/index.vue
xiaomai 4f25f2b2f8 feat(seo): add meta tags and page titles
Configure default head meta and title template in nuxt.config.ts
Add dynamic SEO meta tags and robots directives to all pages
2026-05-08 17:07:43 +08:00

381 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>