Make next event visible to AI crawlers (SSR, JSON-LD, meta, llms.txt)
- SSR next event on homepage; pass initialEvent from server to avoid client-only content - Add schema.org Event JSON-LD on homepage when next event exists - Dynamic homepage metadata (description, OG, Twitter) with next event date - Add dynamic /llms.txt route for AI-friendly plain-text event info - Revalidation: support next-event tag; backend revalidates sitemap + next-event on event CUD - Allow /llms.txt in robots.txt Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from 'next';
|
||||
import HeroSection from './components/HeroSection';
|
||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||
import AboutSection from './components/AboutSection';
|
||||
@@ -5,13 +6,157 @@ import MediaCarouselSection from './components/MediaCarouselSection';
|
||||
import NewsletterSection from './components/NewsletterSection';
|
||||
import { getCarouselImages } from '@/lib/carouselImages';
|
||||
|
||||
export default function HomePage() {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface NextEvent {
|
||||
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' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl?: string;
|
||||
externalBookingEnabled?: boolean;
|
||||
externalBookingUrl?: string;
|
||||
availableSeats?: number;
|
||||
bookedCount?: number;
|
||||
isFeatured?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||
next: { tags: ['next-event'] },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.event || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic metadata with next event date for AI crawlers and SEO
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const event = await getNextUpcomingEvent();
|
||||
|
||||
if (!event) {
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description:
|
||||
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
};
|
||||
}
|
||||
|
||||
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
|
||||
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description,
|
||||
openGraph: {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description,
|
||||
url: siteUrl,
|
||||
siteName: 'Spanglish',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: event.bannerUrl
|
||||
? event.bannerUrl.startsWith('http')
|
||||
? event.bannerUrl
|
||||
: `${siteUrl}${event.bannerUrl}`
|
||||
: `${siteUrl}/images/og-image.jpg`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `Spanglish – ${event.title}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateNextEventJsonLd(event: NextEvent) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: event.title,
|
||||
description: event.shortDescription || event.description,
|
||||
startDate: event.startDatetime,
|
||||
endDate: event.endDatetime || event.startDatetime,
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus:
|
||||
event.status === 'cancelled'
|
||||
? '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:
|
||||
(event.availableSeats ?? 0) > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const carouselImages = getCarouselImages();
|
||||
const nextEvent = await getNextUpcomingEvent();
|
||||
|
||||
return (
|
||||
<>
|
||||
{nextEvent && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(generateNextEventJsonLd(nextEvent)),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<HeroSection />
|
||||
<NextEventSectionWrapper />
|
||||
<NextEventSectionWrapper initialEvent={nextEvent} />
|
||||
<AboutSection />
|
||||
<MediaCarouselSection images={carouselImages} />
|
||||
<NewsletterSection />
|
||||
|
||||
Reference in New Issue
Block a user