dev #3

Merged
Michilis merged 4 commits from dev into main 2026-02-12 04:55:40 +00:00
7 changed files with 348 additions and 23 deletions
Showing only changes of commit 5885044369 - Show all commits

View File

@@ -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' });
});

View File

@@ -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<Event | null>(null);
const [loading, setLoading] = useState(true);
const [nextEvent, setNextEvent] = useState<Event | null>(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', {

View File

@@ -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')}
</h2>
<div className="mt-12 max-w-3xl mx-auto">
<NextEventSection />
<NextEventSection initialEvent={initialEvent} />
</div>
</div>
</section>

View File

@@ -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 />

View File

@@ -12,15 +12,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
// Validate tag
const allowedTags = ['events-sitemap'];
if (!tag || !allowedTags.includes(tag)) {
// Validate tag(s) - supports single tag or array of tags
const allowedTags = ['events-sitemap', 'next-event'];
const tags: string[] = Array.isArray(tag) ? tag : [tag];
const invalidTags = tags.filter((t: string) => !allowedTags.includes(t));
if (tags.length === 0 || invalidTags.length > 0) {
return NextResponse.json({ error: 'Invalid tag' }, { status: 400 });
}
revalidateTag(tag);
for (const t of tags) {
revalidateTag(t);
}
return NextResponse.json({ revalidated: true, tag, now: Date.now() });
return NextResponse.json({ revalidated: true, tags, now: Date.now() });
} catch {
return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 });
}

View File

@@ -0,0 +1,163 @@
import { NextResponse } from 'next/server';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface LlmsEvent {
id: string;
title: string;
titleEs?: string;
shortDescription?: string;
shortDescriptionEs?: string;
description: string;
descriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
price: number;
currency: string;
availableSeats?: number;
status: string;
}
async function getNextUpcomingEvent(): Promise<LlmsEvent | 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;
}
}
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
try {
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
next: { tags: ['next-event'] },
});
if (!response.ok) return [];
const data = await response.json();
return data.events || [];
} catch {
return [];
}
}
function formatEventDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function formatEventTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
function formatPrice(price: number, currency: string): string {
if (price === 0) return 'Free';
return `${price.toLocaleString()} ${currency}`;
}
export async function GET() {
const [nextEvent, upcomingEvents] = await Promise.all([
getNextUpcomingEvent(),
getUpcomingEvents(),
]);
const lines: string[] = [];
// Header
lines.push('# Spanglish Community');
lines.push('');
lines.push('> English-Spanish language exchange community organizing social events and meetups in Asunción, Paraguay.');
lines.push('');
lines.push(`- Website: ${siteUrl}`);
lines.push(`- Events page: ${siteUrl}/events`);
// Social links
const instagram = process.env.NEXT_PUBLIC_INSTAGRAM;
const whatsapp = process.env.NEXT_PUBLIC_WHATSAPP;
const telegram = process.env.NEXT_PUBLIC_TELEGRAM;
const email = process.env.NEXT_PUBLIC_EMAIL;
if (instagram) lines.push(`- Instagram: https://instagram.com/${instagram}`);
if (telegram) lines.push(`- Telegram: https://t.me/${telegram}`);
if (email) lines.push(`- Email: ${email}`);
if (whatsapp) lines.push(`- WhatsApp: ${whatsapp}`);
lines.push('');
// Next Event (most important section for AI)
lines.push('## Next Event');
lines.push('');
if (nextEvent) {
lines.push(`- Event: ${nextEvent.title}`);
lines.push(`- Date: ${formatEventDate(nextEvent.startDatetime)}`);
lines.push(`- Time: ${formatEventTime(nextEvent.startDatetime)}`);
if (nextEvent.endDatetime) {
lines.push(`- End time: ${formatEventTime(nextEvent.endDatetime)}`);
}
lines.push(`- Location: ${nextEvent.location}, Asunción, Paraguay`);
lines.push(`- Price: ${formatPrice(nextEvent.price, nextEvent.currency)}`);
if (nextEvent.availableSeats !== undefined) {
lines.push(`- Available spots: ${nextEvent.availableSeats}`);
}
lines.push(`- Details and tickets: ${siteUrl}/events/${nextEvent.id}`);
if (nextEvent.shortDescription) {
lines.push(`- Description: ${nextEvent.shortDescription}`);
}
} else {
lines.push('No upcoming events currently scheduled. Check back soon or follow us on social media for announcements.');
}
lines.push('');
// All upcoming events
if (upcomingEvents.length > 1) {
lines.push('## All Upcoming Events');
lines.push('');
for (const event of upcomingEvents) {
lines.push(`### ${event.title}`);
lines.push(`- Date: ${formatEventDate(event.startDatetime)}`);
lines.push(`- Time: ${formatEventTime(event.startDatetime)}`);
lines.push(`- Location: ${event.location}, Asunción, Paraguay`);
lines.push(`- Price: ${formatPrice(event.price, event.currency)}`);
lines.push(`- Details: ${siteUrl}/events/${event.id}`);
lines.push('');
}
}
// About section
lines.push('## About Spanglish');
lines.push('');
lines.push('Spanglish is a language exchange community based in Asunción, Paraguay. We organize regular social events where people can practice English and Spanish in a relaxed, friendly environment. Our events bring together locals and internationals for conversation, cultural exchange, and fun.');
lines.push('');
lines.push('## Frequently Asked Questions');
lines.push('');
lines.push('- **What is Spanglish?** A language exchange community that hosts social events to practice English and Spanish.');
lines.push('- **Where are events held?** In Asunción, Paraguay. Specific venues are listed on each event page.');
lines.push('- **How do I attend an event?** Visit the events page to see upcoming events and book tickets.');
lines.push('- **How much do events cost?** Prices vary by event. Some are free, others have a small cover charge.');
lines.push(`- **How do I stay updated?** Follow us on Instagram${instagram ? ` (@${instagram})` : ''}, join our Telegram${telegram ? ` (@${telegram})` : ''}, or check ${siteUrl}/events regularly.`);
lines.push('');
const content = lines.join('\n');
return new NextResponse(content, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
},
});
}

View File

@@ -15,6 +15,7 @@ export default function robots(): MetadataRoute.Robots {
'/contact',
'/faq',
'/legal/*',
'/llms.txt',
],
disallow: [
'/admin',