Add human-readable event URL slugs with legacy redirect support.

Store unique slugs on events, backfill existing records, redirect old UUID and alias URLs to canonical slug pages, and expose slug editing plus alias management in the admin event modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-05 04:09:05 +00:00
parent d09c87a5a5
commit 1b2463f4bc
15 changed files with 361 additions and 33 deletions

View File

@@ -166,7 +166,7 @@ export default function BookingPage() {
const soldOut = bookedCount >= capacity;
if (soldOut) {
toast.error(t('events.details.soldOut'));
router.push(`/events/${eventRes.event.id}`);
router.push(`/events/${eventRes.event.slug}`);
return;
}
@@ -1049,7 +1049,7 @@ export default function BookingPage() {
<div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl">
<Link
href={`/events/${event.id}`}
href={`/events/${event.slug}`}
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
>
<ArrowLeftIcon className="w-4 h-4" />

View File

@@ -69,7 +69,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
}
return (
<Link href={`/events/${nextEvent.id}`} className="block group">
<Link href={`/events/${nextEvent.slug}`} className="block group">
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]">
<div className="flex flex-col md:flex-row">
{/* Banner */}

View File

@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { notFound, permanentRedirect } from 'next/navigation';
import EventDetailClient from './EventDetailClient';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
@@ -7,6 +7,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface Event {
id: string;
slug: string;
title: string;
titleEs?: string;
description: string;
@@ -68,7 +69,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
title,
description,
type: 'website',
url: `${siteUrl}/events/${event.id}`,
url: `${siteUrl}/events/${event.slug}`,
images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }],
},
twitter: {
@@ -78,7 +79,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
images: [imageUrl],
},
alternates: {
canonical: `${siteUrl}/events/${event.id}`,
canonical: `${siteUrl}/events/${event.slug}`,
},
};
}
@@ -119,11 +120,11 @@ function generateEventJsonLd(event: Event) {
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`,
url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(),
},
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`,
url: `${siteUrl}/events/${event.slug}`,
};
}
@@ -134,6 +135,11 @@ export default async function EventDetailPage({ params }: { params: { id: string
notFound();
}
// Redirect legacy UUID/alias URLs to the canonical slug (HTTP 308 permanent)
if (event.slug && params.id !== event.slug) {
permanentRedirect(`/events/${event.slug}`);
}
const jsonLd = generateEventJsonLd(event);
return (
@@ -142,7 +148,7 @@ export default async function EventDetailPage({ params }: { params: { id: string
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<EventDetailClient eventId={params.id} initialEvent={event} />
<EventDetailClient eventId={event.slug} initialEvent={event} />
</>
);
}

View File

@@ -91,7 +91,7 @@ export default function EventsPage() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.id}`} className="block">
<Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (

View File

@@ -13,6 +13,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface NextEvent {
id: string;
slug: string;
title: string;
titleEs?: string;
description: string;
@@ -139,10 +140,10 @@ function generateNextEventJsonLd(event: NextEvent) {
(event.availableSeats ?? 0) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`,
url: `${siteUrl}/events/${event.slug}`,
},
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`,
url: `${siteUrl}/events/${event.slug}`,
};
}

View File

@@ -594,7 +594,7 @@ export default function AdminEventDetailPage() {
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</Button>
<Link href={`/events/${event.id}`} target="_blank">
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
View Public
@@ -618,7 +618,7 @@ export default function AdminEventDetailPage() {
</button>
}
>
<DropdownItem onClick={() => { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem>
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>

View File

@@ -28,9 +28,11 @@ export default function AdminEventsPage() {
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [formData, setFormData] = useState<{
title: string;
titleEs: string;
slug: string;
description: string;
descriptionEs: string;
shortDescription: string;
@@ -49,6 +51,7 @@ export default function AdminEventsPage() {
}>({
title: '',
titleEs: '',
slug: '',
description: '',
descriptionEs: '',
shortDescription: '',
@@ -113,8 +116,9 @@ export default function AdminEventsPage() {
};
const resetForm = () => {
setSlugAliases([]);
setFormData({
title: '', titleEs: '', description: '', descriptionEs: '',
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
@@ -139,9 +143,30 @@ export default function AdminEventsPage() {
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
};
const loadSlugAliases = async (eventId: string) => {
try {
const { aliases } = await eventsApi.getSlugAliases(eventId);
setSlugAliases(aliases);
} catch (error) {
setSlugAliases([]);
}
};
const handleRemoveAlias = async (slug: string) => {
if (!editingEvent) return;
if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return;
try {
await eventsApi.deleteSlugAlias(editingEvent.id, slug);
toast.success('Alias removed');
setSlugAliases((prev) => prev.filter((a) => a.slug !== slug));
} catch (error: any) {
toast.error(error.message || 'Failed to remove alias');
}
};
const handleEdit = (event: Event) => {
setFormData({
title: event.title, titleEs: event.titleEs || '',
title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
description: event.description, descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime),
@@ -154,6 +179,7 @@ export default function AdminEventsPage() {
});
setEditingEvent(event);
setShowForm(true);
loadSlugAliases(event.id);
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -170,7 +196,7 @@ export default function AdminEventsPage() {
setSaving(false);
return;
}
const eventData = {
const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
@@ -183,6 +209,8 @@ export default function AdminEventsPage() {
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
};
if (editingEvent) {
// Only send slug when editing so creates still auto-generate from title
eventData.slug = formData.slug || undefined;
await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated');
} else {
@@ -299,6 +327,38 @@ export default function AdminEventsPage() {
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
</div>
{editingEvent && (
<div>
<Input label="URL Slug" value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="auto-generated from title" />
<p className="text-xs text-gray-500 mt-1">
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
. Changing the slug keeps the old one as a redirecting alias.
</p>
{slugAliases.length > 0 && (
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
<p className="text-sm font-medium mb-2">URL aliases</p>
<p className="text-xs text-gray-500 mb-2">
Old URLs that still redirect to the current slug. Removing one breaks those links.
</p>
<ul className="space-y-1">
{slugAliases.map((alias) => (
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
<span className="font-mono truncate">/events/{alias.slug}</span>
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
title="Remove alias">
<TrashIcon className="w-4 h-4" />
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea value={formData.description}

View File

@@ -77,7 +77,7 @@ export default function LinktreePage() {
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
) : nextEvent ? (
<Link href={`/events/${nextEvent.id}`} className="block group">
<Link href={`/events/${nextEvent.slug}`} className="block group">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}

View File

@@ -11,6 +11,7 @@ interface LlmsFaq {
interface LlmsEvent {
id: string;
slug: string;
title: string;
titleEs?: string;
shortDescription?: string;
@@ -193,7 +194,7 @@ export async function GET() {
if (nextEvent.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
}
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.slug}`);
if (nextEvent.shortDescription) {
lines.push(`- Description: ${nextEvent.shortDescription}`);
}
@@ -226,7 +227,7 @@ export async function GET() {
if (event.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${event.availableSeats}`);
}
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`);
lines.push(`- Tickets URL: ${siteUrl}/events/${event.slug}`);
lines.push('');
}
}

View File

@@ -5,6 +5,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface SitemapEvent {
id: string;
slug: string;
status: string;
startDatetime: string;
updatedAt: string;
@@ -100,7 +101,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const eventPages: MetadataRoute.Sitemap = events.map((event) => {
const isUpcoming = new Date(event.startDatetime) > now;
return {
url: `${siteUrl}/events/${event.id}`,
url: `${siteUrl}/events/${event.slug}`,
lastModified: new Date(event.updatedAt),
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
priority: isUpcoming ? 0.8 : 0.5,

View File

@@ -67,6 +67,12 @@ export const eventsApi = {
duplicate: (id: string) =>
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
getSlugAliases: (id: string) =>
fetchApi<{ aliases: { slug: string; createdAt: string }[] }>(`/api/events/${id}/slug-aliases`),
deleteSlugAlias: (id: string, slug: string) =>
fetchApi<{ message: string }>(`/api/events/${id}/slug-aliases/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
};
// Tickets API
@@ -525,6 +531,7 @@ export const emailsApi = {
// Types
export interface Event {
id: string;
slug: string;
title: string;
titleEs?: string;
description: string;