From 74464b0a7aafb3bbd332b26f05b969b3932e1eb3 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Feb 2026 03:25:09 +0000 Subject: [PATCH 1/4] linktree: next event links to single event page, button 'More info' Co-authored-by: Cursor --- frontend/src/app/linktree/page.tsx | 4 ++-- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/es.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/linktree/page.tsx b/frontend/src/app/linktree/page.tsx index e75daf2..2fd2690 100644 --- a/frontend/src/app/linktree/page.tsx +++ b/frontend/src/app/linktree/page.tsx @@ -80,7 +80,7 @@ export default function LinktreePage() {
) : nextEvent ? ( - +

{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title} @@ -111,7 +111,7 @@ export default function LinktreePage() {

- {t('linktree.bookNow')} + {t('linktree.moreInfo')}
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fa6d1ad..fd93157 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -317,7 +317,7 @@ "tagline": "Language Exchange Community", "nextEvent": "Next Event", "noEvents": "No upcoming events", - "bookNow": "Book Now", + "moreInfo": "More info", "joinCommunity": "Join Our Community", "visitWebsite": "Visit Our Website", "whatsapp": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 2d563ed..a4fd6a6 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -317,7 +317,7 @@ "tagline": "Comunidad de Intercambio de Idiomas", "nextEvent": "Próximo Evento", "noEvents": "No hay eventos próximos", - "bookNow": "Reservar Ahora", + "moreInfo": "Más información", "joinCommunity": "Únete a Nuestra Comunidad", "visitWebsite": "Visitar Nuestro Sitio", "whatsapp": { From af94c99fd2501bc35fa06f6e9b94ea325bfc8810 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Feb 2026 03:51:00 +0000 Subject: [PATCH 2/4] feat: auto-update sitemap when events are added/updated/removed - Use tag-based cache for sitemap event list (events-sitemap) - Add POST /api/revalidate endpoint (secret-protected) to trigger revalidation - Backend calls revalidation after event create/update/delete - Add REVALIDATE_SECRET to .env.example (frontend + backend) Co-authored-by: Cursor --- backend/.env.example | 4 +++ backend/src/routes/events.ts | 31 ++++++++++++++++++++++++ frontend/.env.example | 4 +++ frontend/src/app/api/revalidate/route.ts | 27 +++++++++++++++++++++ frontend/src/app/sitemap.ts | 2 +- 5 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/api/revalidate/route.ts diff --git a/backend/.env.example b/backend/.env.example index 614ac56..19dfe4e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,6 +21,10 @@ PORT=3001 API_URL=http://localhost:3001 FRONTEND_URL=http://localhost:3002 +# Revalidation secret (shared with frontend for on-demand cache revalidation) +# Must match the REVALIDATE_SECRET in frontend/.env +REVALIDATE_SECRET=change-me-to-a-random-secret + # Payment Providers (optional) STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 3eb64fc..ce5b396 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -15,6 +15,28 @@ interface UserContext { const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); +// Trigger frontend sitemap revalidation (fire-and-forget) +function revalidateSitemap() { + 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'); + return; + } + fetch(`${frontendUrl}/api/revalidate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret, tag: 'events-sitemap' }), + }) + .then((res) => { + if (!res.ok) console.error('Sitemap revalidation failed:', res.status); + else console.log('Sitemap revalidation triggered'); + }) + .catch((err) => { + console.error('Sitemap revalidation error:', err.message); + }); +} + // Helper to normalize event data for API response // PostgreSQL decimal returns strings, booleans are stored as integers function normalizeEvent(event: any) { @@ -337,6 +359,9 @@ 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(); + // Return normalized event data return c.json({ event: normalizeEvent(newEvent) }, 201); }); @@ -373,6 +398,9 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', (db as any).select().from(events).where(eq((events as any).id, id)) ); + // Revalidate sitemap when an event is updated (status/dates may have changed) + revalidateSitemap(); + return c.json({ event: normalizeEvent(updated) }); }); @@ -429,6 +457,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { // Finally delete the event await (db as any).delete(events).where(eq((events as any).id, id)); + // Revalidate sitemap when an event is deleted + revalidateSitemap(); + return c.json({ message: 'Event deleted successfully' }); }); diff --git a/frontend/.env.example b/frontend/.env.example index 1e240b6..73dc506 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -21,6 +21,10 @@ NEXT_PUBLIC_EMAIL=hola@spanglish.com.py NEXT_PUBLIC_TELEGRAM=spanglish_py NEXT_PUBLIC_TIKTOK=spanglishsocialpy +# Revalidation secret (shared between frontend and backend for on-demand cache revalidation) +# Must match the REVALIDATE_SECRET in backend/.env +REVALIDATE_SECRET=change-me-to-a-random-secret + # Plausible Analytics (optional - leave empty to disable tracking) NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com diff --git a/frontend/src/app/api/revalidate/route.ts b/frontend/src/app/api/revalidate/route.ts new file mode 100644 index 0000000..42ffdd3 --- /dev/null +++ b/frontend/src/app/api/revalidate/route.ts @@ -0,0 +1,27 @@ +import { revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { secret, tag } = body; + + // Validate the revalidation secret + const revalidateSecret = process.env.REVALIDATE_SECRET; + if (!revalidateSecret || secret !== revalidateSecret) { + return NextResponse.json({ error: 'Invalid secret' }, { status: 401 }); + } + + // Validate tag + const allowedTags = ['events-sitemap']; + if (!tag || !allowedTags.includes(tag)) { + return NextResponse.json({ error: 'Invalid tag' }, { status: 400 }); + } + + revalidateTag(tag); + + return NextResponse.json({ revalidated: true, tag, now: Date.now() }); + } catch { + return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 }); + } +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 72c6a33..d6e5b49 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -12,7 +12,7 @@ interface Event { async function getPublishedEvents(): Promise { try { const response = await fetch(`${apiUrl}/api/events?status=published`, { - next: { revalidate: 3600 }, // Cache for 1 hour + next: { tags: ['events-sitemap'] }, }); if (!response.ok) return []; const data = await response.json(); From 5885044369ab8f43f72823c705837603df7d7c33 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Feb 2026 04:10:49 +0000 Subject: [PATCH 3/4] 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 && ( +