From 5885044369ab8f43f72823c705837603df7d7c33 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Feb 2026 04:10:49 +0000 Subject: [PATCH] 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 --- backend/src/routes/events.ts | 21 +-- .../(public)/components/NextEventSection.tsx | 14 +- .../components/NextEventSectionWrapper.tsx | 9 +- frontend/src/app/(public)/page.tsx | 149 +++++++++++++++- frontend/src/app/api/revalidate/route.ts | 14 +- frontend/src/app/llms.txt/route.ts | 163 ++++++++++++++++++ frontend/src/app/robots.ts | 1 + 7 files changed, 348 insertions(+), 23 deletions(-) create mode 100644 frontend/src/app/llms.txt/route.ts diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index ce5b396..b2ca8c6 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -15,25 +15,26 @@ interface UserContext { const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); -// Trigger frontend sitemap revalidation (fire-and-forget) -function revalidateSitemap() { +// Trigger frontend cache revalidation (fire-and-forget) +// Revalidates both the sitemap and the next-event data (homepage, llms.txt) +function revalidateFrontendCache() { const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002'; const secret = process.env.REVALIDATE_SECRET; if (!secret) { - console.warn('REVALIDATE_SECRET not set, skipping sitemap revalidation'); + console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation'); return; } fetch(`${frontendUrl}/api/revalidate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ secret, tag: 'events-sitemap' }), + body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }), }) .then((res) => { - if (!res.ok) console.error('Sitemap revalidation failed:', res.status); - else console.log('Sitemap revalidation triggered'); + if (!res.ok) console.error('Frontend revalidation failed:', res.status); + else console.log('Frontend revalidation triggered (sitemap + next-event)'); }) .catch((err) => { - console.error('Sitemap revalidation error:', err.message); + console.error('Frontend revalidation error:', err.message); }); } @@ -360,7 +361,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c await (db as any).insert(events).values(newEvent); // Revalidate sitemap when a new event is created - revalidateSitemap(); + revalidateFrontendCache(); // Return normalized event data return c.json({ event: normalizeEvent(newEvent) }, 201); @@ -399,7 +400,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', ); // Revalidate sitemap when an event is updated (status/dates may have changed) - revalidateSitemap(); + revalidateFrontendCache(); return c.json({ event: normalizeEvent(updated) }); }); @@ -458,7 +459,7 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { await (db as any).delete(events).where(eq((events as any).id, id)); // Revalidate sitemap when an event is deleted - revalidateSitemap(); + revalidateFrontendCache(); return c.json({ message: 'Event deleted successfully' }); }); diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index d306795..2fac674 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -9,17 +9,23 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline'; -export default function NextEventSection() { +interface NextEventSectionProps { + initialEvent?: Event | null; +} + +export default function NextEventSection({ initialEvent }: NextEventSectionProps) { const { t, locale } = useLanguage(); - const [nextEvent, setNextEvent] = useState(null); - const [loading, setLoading] = useState(true); + const [nextEvent, setNextEvent] = useState(initialEvent ?? null); + const [loading, setLoading] = useState(!initialEvent); useEffect(() => { + // Skip fetch if we already have server-provided data + if (initialEvent !== undefined) return; eventsApi.getNextUpcoming() .then(({ event }) => setNextEvent(event)) .catch(console.error) .finally(() => setLoading(false)); - }, []); + }, [initialEvent]); const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { diff --git a/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx index a2525ef..9bd78d7 100644 --- a/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx +++ b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx @@ -2,8 +2,13 @@ import { useLanguage } from '@/context/LanguageContext'; import NextEventSection from './NextEventSection'; +import { Event } from '@/lib/api'; -export default function NextEventSectionWrapper() { +interface NextEventSectionWrapperProps { + initialEvent?: Event | null; +} + +export default function NextEventSectionWrapper({ initialEvent }: NextEventSectionWrapperProps) { const { t } = useLanguage(); return ( @@ -13,7 +18,7 @@ export default function NextEventSectionWrapper() { {t('home.nextEvent.title')}
- +
diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index 24ef376..c054bdd 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -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 { + 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 { + 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 && ( +