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} + +
  • + ))} +
+
+ )} +
+ )} +