From 1b2463f4bc24894bb9c92047bdc685376538e634 Mon Sep 17 00:00:00 2001 From: Michilis Date: Fri, 5 Jun 2026 04:09:05 +0000 Subject: [PATCH] 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 --- backend/src/db/migrate.ts | 76 ++++++++- backend/src/db/schema.ts | 17 ++ backend/src/lib/slugify.ts | 27 +++ backend/src/routes/events.ts | 156 ++++++++++++++++-- .../src/app/(public)/book/[eventId]/page.tsx | 4 +- .../(public)/components/NextEventSection.tsx | 2 +- .../src/app/(public)/events/[id]/page.tsx | 18 +- frontend/src/app/(public)/events/page.tsx | 2 +- frontend/src/app/(public)/page.tsx | 5 +- frontend/src/app/admin/events/[id]/page.tsx | 4 +- frontend/src/app/admin/events/page.tsx | 66 +++++++- frontend/src/app/linktree/page.tsx | 2 +- frontend/src/app/llms.txt/route.ts | 5 +- frontend/src/app/sitemap.ts | 3 +- frontend/src/lib/api.ts | 7 + 15 files changed, 361 insertions(+), 33 deletions(-) create mode 100644 backend/src/lib/slugify.ts diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index c085ac0..e9a2750 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; -import { db } from './index.js'; -import { sql } from 'drizzle-orm'; +import { db, dbAll, events } from './index.js'; +import { sql, eq } from 'drizzle-orm'; +import { uniqueSlug } from '../lib/slugify.js'; const dbType = process.env.DB_TYPE || 'sqlite'; console.log(`Database type: ${dbType}`); @@ -111,6 +112,23 @@ async function migrate() { await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`); } catch (e) { /* column may already exist */ } + // Add slug column to events (backfilled below) + try { + await (db as any).run(sql`ALTER TABLE events ADD COLUMN slug TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`); + } catch (e) { /* index may already exist */ } + + // Historical slugs that still resolve (and redirect) to an event's canonical slug + await (db as any).run(sql` + CREATE TABLE IF NOT EXISTS event_slug_aliases ( + slug TEXT PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES events(id), + created_at TEXT NOT NULL + ) + `); + await (db as any).run(sql` CREATE TABLE IF NOT EXISTS tickets ( id TEXT PRIMARY KEY, @@ -579,6 +597,23 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN end_datetime TYPE TIMESTAMPTZ USING end_datetime AT TIME ZONE 'UTC'`); } catch (e) { /* already timestamptz or other issue */ } + // Add slug column to events (backfilled below) + try { + await (db as any).execute(sql`ALTER TABLE events ADD COLUMN slug VARCHAR(255)`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`); + } catch (e) { /* index may already exist */ } + + // Historical slugs that still resolve (and redirect) to an event's canonical slug + await (db as any).execute(sql` + CREATE TABLE IF NOT EXISTS event_slug_aliases ( + slug VARCHAR(255) PRIMARY KEY, + event_id UUID NOT NULL REFERENCES events(id), + created_at TIMESTAMP NOT NULL + ) + `); + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS tickets ( id UUID PRIMARY KEY, @@ -895,6 +930,43 @@ async function migrate() { `); } + // Backfill slugs for any events that don't have one yet (shared across DB types). + // Ordered by creation so duplicate titles get deterministic -2, -3 suffixes. + const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>( + (db as any).select().from(events).orderBy((events as any).createdAt) + ); + const assignedSlugs: string[] = allEvents + .filter((e) => e.slug) + .map((e) => e.slug as string); + let backfilled = 0; + for (const ev of allEvents) { + if (ev.slug) continue; + const slug = uniqueSlug(ev.title || 'event', assignedSlugs); + assignedSlugs.push(slug); + await (db as any).update(events).set({ slug }).where(eq((events as any).id, ev.id)); + backfilled++; + } + if (backfilled > 0) { + console.log(`Backfilled slugs for ${backfilled} event(s).`); + // Bust the frontend cache so the homepage / sitemap pick up the new slugs + // immediately instead of serving stale (pre-slug) data for up to the + // revalidate window. Awaited (not fire-and-forget) so it runs before exit. + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002'; + const secret = process.env.REVALIDATE_SECRET; + if (secret) { + try { + const res = await fetch(`${frontendUrl}/api/revalidate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }), + }); + console.log(res.ok ? 'Frontend cache revalidated.' : `Frontend revalidation returned ${res.status}.`); + } catch (e: any) { + console.warn('Frontend revalidation skipped (frontend not reachable):', e?.message || e); + } + } + } + console.log('Migrations completed successfully!'); process.exit(0); } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index d2b672a..ea05a9c 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -62,6 +62,7 @@ export const sqliteInvoices = sqliteTable('invoices', { export const sqliteEvents = sqliteTable('events', { id: text('id').primaryKey(), + slug: text('slug').unique(), title: text('title').notNull(), titleEs: text('title_es'), description: text('description').notNull(), @@ -83,6 +84,13 @@ export const sqliteEvents = sqliteTable('events', { updatedAt: text('updated_at').notNull(), }); +// Historical slugs that still resolve (and redirect) to an event's canonical slug +export const sqliteEventSlugAliases = sqliteTable('event_slug_aliases', { + slug: text('slug').primaryKey(), + eventId: text('event_id').notNull().references(() => sqliteEvents.id), + createdAt: text('created_at').notNull(), +}); + export const sqliteTickets = sqliteTable('tickets', { id: text('id').primaryKey(), bookingId: text('booking_id'), // Groups multiple tickets from same booking @@ -387,6 +395,7 @@ export const pgInvoices = pgTable('invoices', { export const pgEvents = pgTable('events', { id: uuid('id').primaryKey(), + slug: varchar('slug', { length: 255 }).unique(), title: varchar('title', { length: 255 }).notNull(), titleEs: varchar('title_es', { length: 255 }), description: pgText('description').notNull(), @@ -408,6 +417,13 @@ export const pgEvents = pgTable('events', { updatedAt: timestamp('updated_at').notNull(), }); +// Historical slugs that still resolve (and redirect) to an event's canonical slug +export const pgEventSlugAliases = pgTable('event_slug_aliases', { + slug: varchar('slug', { length: 255 }).primaryKey(), + eventId: uuid('event_id').notNull().references(() => pgEvents.id), + createdAt: timestamp('created_at').notNull(), +}); + export const pgTickets = pgTable('tickets', { id: uuid('id').primaryKey(), bookingId: uuid('booking_id'), // Groups multiple tickets from same booking @@ -649,6 +665,7 @@ export const pgSiteSettings = pgTable('site_settings', { // Export the appropriate schema based on DB_TYPE export const users = dbType === 'postgres' ? pgUsers : sqliteUsers; export const events = dbType === 'postgres' ? pgEvents : sqliteEvents; +export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases; export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets; export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments; export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts; diff --git a/backend/src/lib/slugify.ts b/backend/src/lib/slugify.ts new file mode 100644 index 0000000..de847ce --- /dev/null +++ b/backend/src/lib/slugify.ts @@ -0,0 +1,27 @@ +/** + * Convert a title into a URL-safe slug. + * Lowercases, strips accents, replaces non-alphanumerics with hyphens, + * collapses repeated hyphens, and trims leading/trailing hyphens. + */ +export function slugify(title: string): string { + return title + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Generate a slug from a title that does not collide with any of the + * provided existing slugs. Appends -2, -3, ... when needed. + */ +export function uniqueSlug(title: string, existingSlugs: string[]): string { + const base = slugify(title) || 'event'; + const taken = new Set(existingSlugs); + if (!taken.has(base)) return base; + let n = 2; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index bc7d228..c7ed4ad 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -1,10 +1,11 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; +import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js'; import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; +import { slugify, uniqueSlug } from '../lib/slugify.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; interface UserContext { @@ -31,6 +32,55 @@ function normalizeEvent(event: any) { }; } +// Load every slug currently in use (canonical event slugs + historical aliases), +// optionally excluding a given event's own canonical slug + aliases. +async function getAllSlugsInUse(excludeEventId?: string): Promise { + const eventRows = await dbAll( + (db as any).select({ id: (events as any).id, slug: (events as any).slug }).from(events) + ); + const aliasRows = await dbAll( + (db as any).select({ eventId: (eventSlugAliases as any).eventId, slug: (eventSlugAliases as any).slug }).from(eventSlugAliases) + ); + const slugs: string[] = []; + for (const row of eventRows) { + if (row.slug && row.id !== excludeEventId) slugs.push(row.slug); + } + for (const row of aliasRows) { + if (row.slug && row.eventId !== excludeEventId) slugs.push(row.slug); + } + return slugs; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// Resolve an event by canonical slug, primary id, or a historical slug alias. +// Slug is checked first because Postgres rejects non-UUID strings when comparing +// against the uuid `id` column, so id lookups are guarded behind a UUID check there. +async function resolveEventByParam(param: string): Promise { + let event = await dbGet( + (db as any).select().from(events).where(eq((events as any).slug, param)) + ); + + if (!event && (!isPostgres() || UUID_PATTERN.test(param))) { + event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, param)) + ); + } + + if (!event) { + const alias = await dbGet( + (db as any).select().from(eventSlugAliases).where(eq((eventSlugAliases as any).slug, param)) + ); + if (alias) { + event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, alias.eventId)) + ); + } + } + + return event || null; +} + // Custom validation error handler const validationHook = (result: any, c: any) => { if (!result.success) { @@ -63,6 +113,7 @@ const normalizeBoolean = (val: unknown): boolean => { const baseEventSchema = z.object({ title: z.string().min(1), titleEs: z.string().optional().nullable(), + slug: z.string().optional(), description: z.string().min(1), descriptionEs: z.string().optional().nullable(), shortDescription: z.string().max(300).optional().nullable(), @@ -164,13 +215,10 @@ eventsRouter.get('/', async (c) => { return c.json({ events: eventsWithCounts }); }); -// Get single event (public) +// Get single event (public) - resolves by id, canonical slug, or historical alias eventsRouter.get('/:id', async (c) => { - const id = c.req.param('id'); - - const event = await dbGet( - (db as any).select().from(events).where(eq((events as any).id, id)) - ); + const param = c.req.param('id'); + const event = await resolveEventByParam(param); if (!event) { return c.json({ error: 'Event not found' }, 404); @@ -184,7 +232,7 @@ eventsRouter.get('/:id', async (c) => { .from(tickets) .where( and( - eq((tickets as any).eventId, id), + eq((tickets as any).eventId, event.id), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) @@ -328,9 +376,14 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c // Convert data for database compatibility const dbData = convertBooleansForDb(data); + // Generate a unique slug from the title (manual slug is honored on update, not create) + const existingSlugs = await getAllSlugsInUse(); + const slug = uniqueSlug(data.title, existingSlugs); + const newEvent = { id, ...dbData, + slug, startDatetime: toDbDateTz(data.startDatetime, tz), endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null, createdAt: now, @@ -351,7 +404,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', const id = c.req.param('id'); const data = c.req.valid('json'); - const existing = await dbGet( + const existing = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); if (!existing) { @@ -362,6 +415,8 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', const tz = await getSiteTimezone(); // Convert data for database compatibility const updateData: Record = { ...convertBooleansForDb(data), updatedAt: now }; + // Slug changes are handled explicitly below to manage aliases + delete updateData.slug; // Convert datetime fields if present if (data.startDatetime) { updateData.startDatetime = toDbDateTz(data.startDatetime, tz); @@ -370,6 +425,40 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null; } + // Resolve slug: explicit admin edit takes priority, then title-derived regeneration + const oldSlug: string | null = existing.slug || null; + let newSlug: string | null = oldSlug; + if (typeof data.slug === 'string' && data.slug.trim() !== '') { + const normalized = slugify(data.slug); + if (!normalized) { + return c.json({ error: 'Invalid slug' }, 400); + } + if (normalized !== oldSlug) { + const taken = await getAllSlugsInUse(id); + if (taken.includes(normalized)) { + return c.json({ error: 'Slug already in use' }, 400); + } + newSlug = normalized; + } + } else if (data.title && slugify(data.title) !== slugify(existing.title || '')) { + const taken = await getAllSlugsInUse(id); + newSlug = uniqueSlug(data.title, taken); + } + + if (newSlug && newSlug !== oldSlug) { + // If this slug was previously one of THIS event's aliases, reclaim it as canonical + await (db as any) + .delete(eventSlugAliases) + .where(and(eq((eventSlugAliases as any).slug, newSlug), eq((eventSlugAliases as any).eventId, id))); + // Preserve the old slug as an alias so existing shared links keep redirecting + if (oldSlug) { + try { + await (db as any).insert(eventSlugAliases).values({ slug: oldSlug, eventId: id, createdAt: now }); + } catch (e) { /* alias may already exist */ } + } + updateData.slug = newSlug; + } + await (db as any) .update(events) .set(updateData) @@ -429,6 +518,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { // Delete event payment overrides await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id)); + // Delete slug aliases for this event + await (db as any).delete(eventSlugAliases).where(eq((eventSlugAliases as any).eventId, id)); + // Set eventId to null on email logs (they reference this event but can exist without it) await (db as any) .update(emailLogs) @@ -471,11 +563,15 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( const now = getNow(); const newId = generateId(); + const duplicatedTitle = `${existing.title} (Copy)`; + const existingSlugs = await getAllSlugsInUse(); + const slug = uniqueSlug(duplicatedTitle, existingSlugs); // Create a copy with modified title and draft status const duplicatedEvent = { id: newId, - title: `${existing.title} (Copy)`, + slug, + title: duplicatedTitle, titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null, description: existing.description, descriptionEs: existing.descriptionEs, @@ -501,4 +597,44 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201); }); +// List slug aliases for an event (admin/organizer only) +eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => { + const id = c.req.param('id'); + + const existing = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, id)) + ); + if (!existing) { + return c.json({ error: 'Event not found' }, 404); + } + + const aliases = await dbAll( + (db as any) + .select({ slug: (eventSlugAliases as any).slug, createdAt: (eventSlugAliases as any).createdAt }) + .from(eventSlugAliases) + .where(eq((eventSlugAliases as any).eventId, id)) + ); + + return c.json({ aliases }); +}); + +// Remove a slug alias from an event (admin/organizer only) +eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => { + const id = c.req.param('id'); + const slug = c.req.param('slug'); + + const existing = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, id)) + ); + if (!existing) { + return c.json({ error: 'Event not found' }, 404); + } + + await (db as any) + .delete(eventSlugAliases) + .where(and(eq((eventSlugAliases as any).eventId, id), eq((eventSlugAliases as any).slug, slug))); + + return c.json({ message: 'Alias removed' }); +}); + export default eventsRouter; diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 5cc5740..790a329 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -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() {
diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index 5a50712..534df66 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -69,7 +69,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps } return ( - +
{/* Banner */} diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 9d78063..1ef74ba 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -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) }} /> - + ); } diff --git a/frontend/src/app/(public)/events/page.tsx b/frontend/src/app/(public)/events/page.tsx index 9ecbc7b..a247a46 100644 --- a/frontend/src/app/(public)/events/page.tsx +++ b/frontend/src/app/(public)/events/page.tsx @@ -91,7 +91,7 @@ export default function EventsPage() { ) : (
{displayedEvents.map((event) => ( - + {/* Event banner */} {event.bannerUrl ? ( diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index c9dd594..3ddffab 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -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}`, }; } diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 87f8944..5e44fb6 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -594,7 +594,7 @@ export default function AdminEventDetailPage() { {showStats ? : } {showStats ? 'Hide Stats' : 'Show Stats'} - + } > - { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}> + { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}> View Public { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index e29cba4..876e26d 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -28,9 +28,11 @@ export default function AdminEventsPage() { const [featuredEventId, setFeaturedEventId] = useState(null); const [settingFeatured, setSettingFeatured] = useState(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 = { 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 })} />
+ {editingEvent && ( +
+ setFormData({ ...formData, slug: e.target.value })} + placeholder="auto-generated from title" /> +

+ Public URL: /events/{formData.slug || '...'} + . Changing the slug keeps the old one as a redirecting alias. +

+ {slugAliases.length > 0 && ( +
+

URL aliases

+

+ Old URLs that still redirect to the current slug. Removing one breaks those links. +

+
    + {slugAliases.map((alias) => ( +
  • + /events/{alias.slug} + +
  • + ))} +
+
+ )} +
+ )} +