Add full SEO optimization for Spanglish social and language events

- Add comprehensive metadata to root layout with Open Graph, Twitter cards
- Create dynamic sitemap.ts for all pages and events
- Create robots.ts with proper allow/disallow rules
- Add JSON-LD Event structured data to event detail pages
- Add page-specific metadata to events, community, contact, FAQ pages
- Add FAQ structured data schema
- Update footer with local SEO text for Asunción, Paraguay
- Add web manifest for mobile SEO
- Create 404 page with proper noindex
- Optimize image alt text and add lazy loading
- Add NEXT_PUBLIC_SITE_URL env variable
- Add about/ folder to gitignore
This commit is contained in:
root
2026-01-30 21:05:25 +00:00
parent d0ea55dc5b
commit 47ba754f05
40 changed files with 2659 additions and 420 deletions

View File

@@ -0,0 +1,199 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
import {
CalendarIcon,
MapPinIcon,
UserGroupIcon,
ArrowLeftIcon,
} from '@heroicons/react/24/outline';
interface EventDetailClientProps {
eventId: string;
initialEvent: Event;
}
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
const { t, locale } = useLanguage();
const [event, setEvent] = useState<Event>(initialEvent);
// Refresh event data on client for real-time availability
useEffect(() => {
eventsApi.getById(eventId)
.then(({ event }) => setEvent(event))
.catch(console.error);
}, [eventId]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const isSoldOut = event.availableSeats === 0;
const isCancelled = event.status === 'cancelled';
const isPastEvent = new Date(event.startDatetime) < new Date();
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
return (
<div className="section-padding">
<div className="container-page">
<Link
href="/events"
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Event Details */}
<div className="lg:col-span-2">
<Card className="overflow-hidden">
{/* Banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-64 w-full object-cover"
loading="eager"
/>
) : (
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
</div>
)}
<div className="p-8">
<div className="flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="flex items-start gap-3">
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.date')}</p>
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl"></span>
<div>
<p className="font-medium">{t('events.details.time')}</p>
<p className="text-gray-600">{formatTime(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
)}
</div>
</div>
<div className="flex items-start gap-3">
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
</div>
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line">
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
</div>
{/* Social Sharing */}
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
</div>
</div>
</Card>
</div>
{/* Booking Card */}
<div className="lg:col-span-1">
<Card className="p-6 sticky top-24">
<div className="text-center mb-6">
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
<p className="text-4xl font-bold text-primary-dark">
{event.price === 0
? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`}
</p>
</div>
{canBook ? (
<Link href={`/book/${event.id}`}>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</Link>
) : (
<Button className="w-full" size="lg" disabled>
{isPastEvent
? t('events.details.eventEnded')
: isSoldOut
? t('events.details.soldOut')
: t('events.details.cancelled')}
</Button>
)}
<p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')}
</p>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,213 +1,147 @@
'use client';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import EventDetailClient from './EventDetailClient';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
import {
CalendarIcon,
MapPinIcon,
UserGroupIcon,
ArrowLeftIcon,
} from '@heroicons/react/24/outline';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export default function EventDetailPage() {
const params = useParams();
const router = useRouter();
const { t, locale } = useLanguage();
const [event, setEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(true);
interface Event {
id: string;
title: string;
titleEs?: string;
description: string;
descriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
locationUrl?: string;
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
availableSeats?: number;
bookedCount?: number;
createdAt: string;
updatedAt: string;
}
useEffect(() => {
if (params.id) {
eventsApi.getById(params.id as string)
.then(({ event }) => setEvent(event))
.catch(() => router.push('/events'))
.finally(() => setLoading(false));
}
}, [params.id, router]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
async function getEvent(id: string): Promise<Event | null> {
try {
const response = await fetch(`${apiUrl}/api/events/${id}`, {
next: { revalidate: 60 },
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<div className="section-padding">
<div className="container-page text-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
</div>
);
}
if (!event) {
if (!response.ok) return null;
const data = await response.json();
return data.event || null;
} catch {
return null;
}
}
const isSoldOut = event.availableSeats === 0;
const isCancelled = event.status === 'cancelled';
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const event = await getEvent(params.id);
if (!event) {
return { title: 'Event Not Found' };
}
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const title = `${event.title} English & Spanish Meetup in Asunción`;
const description = `Join Spanglish on ${eventDate} in Asunción. Practice English and Spanish in a relaxed social setting. Limited spots available.`;
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
url: `${siteUrl}/events/${event.id}`,
images: event.bannerUrl
? [{ url: event.bannerUrl, width: 1200, height: 630, alt: event.title }]
: [{ url: `${siteUrl}/images/og-image.jpg`, width: 1200, height: 630, alt: 'Spanglish Language Exchange Event' }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: event.bannerUrl ? [event.bannerUrl] : [`${siteUrl}/images/og-image.jpg`],
},
alternates: {
canonical: `${siteUrl}/events/${event.id}`,
},
};
}
function generateEventJsonLd(event: Event) {
const isPastEvent = new Date(event.startDatetime) < new Date();
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
const isCancelled = event.status === 'cancelled';
return {
'@context': 'https://schema.org',
'@type': 'Event',
name: event.title,
description: event.description,
startDate: event.startDatetime,
endDate: event.endDatetime || event.startDatetime,
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus: isCancelled
? 'https://schema.org/EventCancelled'
: isPastEvent
? 'https://schema.org/EventPostponed'
: '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 && event.availableSeats > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`,
validFrom: new Date().toISOString(),
},
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`,
};
}
export default async function EventDetailPage({ params }: { params: { id: string } }) {
const event = await getEvent(params.id);
if (!event) {
notFound();
}
const jsonLd = generateEventJsonLd(event);
return (
<div className="section-padding">
<div className="container-page">
<Link
href="/events"
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Event Details */}
<div className="lg:col-span-2">
<Card className="overflow-hidden">
{/* Banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={event.title}
className="h-64 w-full object-cover"
/>
) : (
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
</div>
)}
<div className="p-8">
<div className="flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="flex items-start gap-3">
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.date')}</p>
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl"></span>
<div>
<p className="font-medium">{t('events.details.time')}</p>
<p className="text-gray-600">{formatTime(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
)}
</div>
</div>
<div className="flex items-start gap-3">
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
</div>
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line">
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
</div>
{/* Social Sharing */}
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
</div>
</div>
</Card>
</div>
{/* Booking Card */}
<div className="lg:col-span-1">
<Card className="p-6 sticky top-24">
<div className="text-center mb-6">
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
<p className="text-4xl font-bold text-primary-dark">
{event.price === 0
? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`}
</p>
</div>
{canBook ? (
<Link href={`/book/${event.id}`}>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</Link>
) : (
<Button className="w-full" size="lg" disabled>
{isPastEvent
? t('events.details.eventEnded')
: isSoldOut
? t('events.details.soldOut')
: t('events.details.cancelled')}
</Button>
)}
<p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')}
</p>
</Card>
</div>
</div>
</div>
</div>
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<EventDetailClient eventId={params.id} initialEvent={event} />
</>
);
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Upcoming Language Exchange Events in Asunción',
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
openGraph: {
title: 'Upcoming Language Exchange Events in Asunción Spanglish',
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
},
};
export default function EventsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -108,8 +108,9 @@ export default function EventsPage() {
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={event.title}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-40 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">