- Backend: add 'unlisted' to schema enum and Zod validation; allow booking for unlisted events - Frontend: Event type and guards updated; unlisted events bookable, excluded from public listing/sitemap - Admin: badge, status dropdown, Make Unlisted / Make Public / Unpublish actions; scanner/emails/tickets include unlisted Co-authored-by: Cursor <cursoragent@cursor.com>
149 lines
4.0 KiB
TypeScript
149 lines
4.0 KiB
TypeScript
import type { Metadata } from 'next';
|
|
import { notFound } from 'next/navigation';
|
|
import EventDetailClient from './EventDetailClient';
|
|
|
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|
|
|
interface Event {
|
|
id: string;
|
|
title: string;
|
|
titleEs?: string;
|
|
description: string;
|
|
descriptionEs?: string;
|
|
shortDescription?: string;
|
|
shortDescriptionEs?: string;
|
|
startDatetime: string;
|
|
endDatetime?: string;
|
|
location: string;
|
|
locationUrl?: string;
|
|
price: number;
|
|
currency: string;
|
|
capacity: number;
|
|
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
|
bannerUrl?: string;
|
|
availableSeats?: number;
|
|
bookedCount?: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
async function getEvent(id: string): Promise<Event | null> {
|
|
try {
|
|
const response = await fetch(`${apiUrl}/api/events/${id}`, {
|
|
next: { revalidate: 60 },
|
|
});
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
return data.event || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
|
const event = await getEvent(params.id);
|
|
|
|
if (!event) {
|
|
return { title: 'Event Not Found' };
|
|
}
|
|
|
|
const title = event.title;
|
|
// Use short description if available, otherwise fall back to truncated full description
|
|
const description = event.shortDescription
|
|
? event.shortDescription
|
|
: (event.description.length > 155
|
|
? event.description.slice(0, 152).trim() + '...'
|
|
: event.description);
|
|
|
|
// Convert relative banner URL to absolute URL for SEO
|
|
const imageUrl = event.bannerUrl
|
|
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
|
: `${siteUrl}/images/og-image.jpg`;
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
openGraph: {
|
|
title,
|
|
description,
|
|
type: 'website',
|
|
url: `${siteUrl}/events/${event.id}`,
|
|
images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }],
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title,
|
|
description,
|
|
images: [imageUrl],
|
|
},
|
|
alternates: {
|
|
canonical: `${siteUrl}/events/${event.id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function generateEventJsonLd(event: Event) {
|
|
const isPastEvent = new Date(event.startDatetime) < new Date();
|
|
const isCancelled = event.status === 'cancelled';
|
|
|
|
return {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Event',
|
|
name: event.title,
|
|
description: event.description,
|
|
startDate: event.startDatetime,
|
|
endDate: event.endDatetime || event.startDatetime,
|
|
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
|
eventStatus: isCancelled
|
|
? 'https://schema.org/EventCancelled'
|
|
: 'https://schema.org/EventScheduled',
|
|
location: {
|
|
'@type': 'Place',
|
|
name: event.location,
|
|
address: {
|
|
'@type': 'PostalAddress',
|
|
addressLocality: 'Asunción',
|
|
addressCountry: 'PY',
|
|
},
|
|
},
|
|
organizer: {
|
|
'@type': 'Organization',
|
|
name: 'Spanglish',
|
|
url: siteUrl,
|
|
},
|
|
offers: {
|
|
'@type': 'Offer',
|
|
price: event.price,
|
|
priceCurrency: event.currency,
|
|
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
|
|
? 'https://schema.org/InStock'
|
|
: 'https://schema.org/SoldOut',
|
|
url: `${siteUrl}/events/${event.id}`,
|
|
validFrom: new Date().toISOString(),
|
|
},
|
|
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
|
url: `${siteUrl}/events/${event.id}`,
|
|
};
|
|
}
|
|
|
|
export default async function EventDetailPage({ params }: { params: { id: string } }) {
|
|
const event = await getEvent(params.id);
|
|
|
|
if (!event) {
|
|
notFound();
|
|
}
|
|
|
|
const jsonLd = generateEventJsonLd(event);
|
|
|
|
return (
|
|
<>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
/>
|
|
<EventDetailClient eventId={params.id} initialEvent={event} />
|
|
</>
|
|
);
|
|
}
|