dev #3
@@ -15,25 +15,26 @@ interface UserContext {
|
|||||||
|
|
||||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
// Trigger frontend sitemap revalidation (fire-and-forget)
|
// Trigger frontend cache revalidation (fire-and-forget)
|
||||||
function revalidateSitemap() {
|
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
||||||
|
function revalidateFrontendCache() {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||||
const secret = process.env.REVALIDATE_SECRET;
|
const secret = process.env.REVALIDATE_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
console.warn('REVALIDATE_SECRET not set, skipping sitemap revalidation');
|
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch(`${frontendUrl}/api/revalidate`, {
|
fetch(`${frontendUrl}/api/revalidate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ secret, tag: 'events-sitemap' }),
|
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) console.error('Sitemap revalidation failed:', res.status);
|
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
||||||
else console.log('Sitemap revalidation triggered');
|
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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);
|
await (db as any).insert(events).values(newEvent);
|
||||||
|
|
||||||
// Revalidate sitemap when a new event is created
|
// Revalidate sitemap when a new event is created
|
||||||
revalidateSitemap();
|
revalidateFrontendCache();
|
||||||
|
|
||||||
// Return normalized event data
|
// Return normalized event data
|
||||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
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)
|
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||||
revalidateSitemap();
|
revalidateFrontendCache();
|
||||||
|
|
||||||
return c.json({ event: normalizeEvent(updated) });
|
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));
|
await (db as any).delete(events).where(eq((events as any).id, id));
|
||||||
|
|
||||||
// Revalidate sitemap when an event is deleted
|
// Revalidate sitemap when an event is deleted
|
||||||
revalidateSitemap();
|
revalidateFrontendCache();
|
||||||
|
|
||||||
return c.json({ message: 'Event deleted successfully' });
|
return c.json({ message: 'Event deleted successfully' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,17 +9,23 @@ import Button from '@/components/ui/Button';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
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 { t, locale } = useLanguage();
|
||||||
const [nextEvent, setNextEvent] = useState<Event | null>(null);
|
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialEvent);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip fetch if we already have server-provided data
|
||||||
|
if (initialEvent !== undefined) return;
|
||||||
eventsApi.getNextUpcoming()
|
eventsApi.getNextUpcoming()
|
||||||
.then(({ event }) => setNextEvent(event))
|
.then(({ event }) => setNextEvent(event))
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, [initialEvent]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import NextEventSection from './NextEventSection';
|
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();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,7 +18,7 @@ export default function NextEventSectionWrapper() {
|
|||||||
{t('home.nextEvent.title')}
|
{t('home.nextEvent.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-12 max-w-3xl mx-auto">
|
<div className="mt-12 max-w-3xl mx-auto">
|
||||||
<NextEventSection />
|
<NextEventSection initialEvent={initialEvent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import HeroSection from './components/HeroSection';
|
import HeroSection from './components/HeroSection';
|
||||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||||
import AboutSection from './components/AboutSection';
|
import AboutSection from './components/AboutSection';
|
||||||
@@ -5,13 +6,157 @@ import MediaCarouselSection from './components/MediaCarouselSection';
|
|||||||
import NewsletterSection from './components/NewsletterSection';
|
import NewsletterSection from './components/NewsletterSection';
|
||||||
import { getCarouselImages } from '@/lib/carouselImages';
|
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 carouselImages = getCarouselImages();
|
||||||
|
const nextEvent = await getNextUpcomingEvent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{nextEvent && (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(generateNextEventJsonLd(nextEvent)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<NextEventSectionWrapper />
|
<NextEventSectionWrapper initialEvent={nextEvent} />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
<MediaCarouselSection images={carouselImages} />
|
<MediaCarouselSection images={carouselImages} />
|
||||||
<NewsletterSection />
|
<NewsletterSection />
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate tag
|
// Validate tag(s) - supports single tag or array of tags
|
||||||
const allowedTags = ['events-sitemap'];
|
const allowedTags = ['events-sitemap', 'next-event'];
|
||||||
if (!tag || !allowedTags.includes(tag)) {
|
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 });
|
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 {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
163
frontend/src/app/llms.txt/route.ts
Normal file
163
frontend/src/app/llms.txt/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
'/contact',
|
'/contact',
|
||||||
'/faq',
|
'/faq',
|
||||||
'/legal/*',
|
'/legal/*',
|
||||||
|
'/llms.txt',
|
||||||
],
|
],
|
||||||
disallow: [
|
disallow: [
|
||||||
'/admin',
|
'/admin',
|
||||||
|
|||||||
Reference in New Issue
Block a user