9 Commits

Author SHA1 Message Date
c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis
1b2463f4bc 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>
2026-06-05 04:09:05 +00:00
fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis
d09c87a5a5 Fix booking flow scroll position on mobile step changes.
Scroll to the top whenever the booking step changes so users are not left at the bottom of the page after submitting the form.
2026-06-05 02:52:14 +00:00
e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis
69768077e5 Add search and event filters to admin email logs.
Let admins find logs by recipient or subject and narrow results by event on the Email Logs tab.
2026-06-04 23:35:53 +00:00
Michilis
ecd2a7d009 fix(legal): align bullet list markers with list text
Use list-outside and left padding so ReactMarkdown and TipTap list items render markers beside text instead of on separate lines.
2026-06-04 22:58:31 +00:00
Michilis
0f7573c934 Cap event page ticket quantity at 5 per booking.
Limit the quantity stepper to five tickets or remaining spots, whichever is lower.
2026-06-04 22:53:39 +00:00
Michilis
a8b72b47b1 fix(legal): show legal pages in site language with bidirectional toggle
Wire legal pages to LanguageContext, pass locale on footer/booking links,
translate layout chrome, and restore SSR content when switching back to English.
2026-06-04 22:51:32 +00:00
24 changed files with 521 additions and 50 deletions

View File

@@ -1,6 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import { db } from './index.js'; import { db, dbAll, events } from './index.js';
import { sql } from 'drizzle-orm'; import { sql, eq } from 'drizzle-orm';
import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite'; const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`); 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`); await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`);
} catch (e) { /* column may already exist */ } } 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` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY, 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'`); 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 */ } } 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` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id UUID PRIMARY KEY, 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!'); console.log('Migrations completed successfully!');
process.exit(0); process.exit(0);
} }

View File

@@ -62,6 +62,7 @@ export const sqliteInvoices = sqliteTable('invoices', {
export const sqliteEvents = sqliteTable('events', { export const sqliteEvents = sqliteTable('events', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
slug: text('slug').unique(),
title: text('title').notNull(), title: text('title').notNull(),
titleEs: text('title_es'), titleEs: text('title_es'),
description: text('description').notNull(), description: text('description').notNull(),
@@ -83,6 +84,13 @@ export const sqliteEvents = sqliteTable('events', {
updatedAt: text('updated_at').notNull(), 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', { export const sqliteTickets = sqliteTable('tickets', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
bookingId: text('booking_id'), // Groups multiple tickets from same booking bookingId: text('booking_id'), // Groups multiple tickets from same booking
@@ -387,6 +395,7 @@ export const pgInvoices = pgTable('invoices', {
export const pgEvents = pgTable('events', { export const pgEvents = pgTable('events', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
slug: varchar('slug', { length: 255 }).unique(),
title: varchar('title', { length: 255 }).notNull(), title: varchar('title', { length: 255 }).notNull(),
titleEs: varchar('title_es', { length: 255 }), titleEs: varchar('title_es', { length: 255 }),
description: pgText('description').notNull(), description: pgText('description').notNull(),
@@ -408,6 +417,13 @@ export const pgEvents = pgTable('events', {
updatedAt: timestamp('updated_at').notNull(), 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', { export const pgTickets = pgTable('tickets', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking 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 the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers; export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents; export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets; export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments; export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts; export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;

View File

@@ -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}`;
}

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js'; import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, sql } from 'drizzle-orm'; import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
@@ -287,6 +287,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');
const status = c.req.query('status'); const status = c.req.query('status');
const search = c.req.query('search');
const limit = parseInt(c.req.query('limit') || '50'); const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0'); const offset = parseInt(c.req.query('offset') || '0');
@@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
if (status) { if (status) {
conditions.push(eq((emailLogs as any).status, status)); conditions.push(eq((emailLogs as any).status, status));
} }
if (search && search.trim()) {
const term = `%${search.trim().toLowerCase()}%`;
conditions.push(or(
sql`LOWER(${(emailLogs as any).recipientEmail}) LIKE ${term}`,
sql`LOWER(COALESCE(${(emailLogs as any).recipientName}, '')) LIKE ${term}`,
sql`LOWER(${(emailLogs as any).subject}) LIKE ${term}`,
));
}
if (conditions.length > 0) { if (conditions.length > 0) {
query = query.where(and(...conditions)); query = query.where(and(...conditions));

View File

@@ -1,10 +1,11 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; 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 { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.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'; import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext { 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<string[]> {
const eventRows = await dbAll<any>(
(db as any).select({ id: (events as any).id, slug: (events as any).slug }).from(events)
);
const aliasRows = await dbAll<any>(
(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<any | null> {
let event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).slug, param))
);
if (!event && (!isPostgres() || UUID_PATTERN.test(param))) {
event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, param))
);
}
if (!event) {
const alias = await dbGet<any>(
(db as any).select().from(eventSlugAliases).where(eq((eventSlugAliases as any).slug, param))
);
if (alias) {
event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, alias.eventId))
);
}
}
return event || null;
}
// Custom validation error handler // Custom validation error handler
const validationHook = (result: any, c: any) => { const validationHook = (result: any, c: any) => {
if (!result.success) { if (!result.success) {
@@ -63,6 +113,7 @@ const normalizeBoolean = (val: unknown): boolean => {
const baseEventSchema = z.object({ const baseEventSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
titleEs: z.string().optional().nullable(), titleEs: z.string().optional().nullable(),
slug: z.string().optional(),
description: z.string().min(1), description: z.string().min(1),
descriptionEs: z.string().optional().nullable(), descriptionEs: z.string().optional().nullable(),
shortDescription: z.string().max(300).optional().nullable(), shortDescription: z.string().max(300).optional().nullable(),
@@ -164,13 +215,10 @@ eventsRouter.get('/', async (c) => {
return c.json({ events: eventsWithCounts }); 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) => { eventsRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const param = c.req.param('id');
const event = await resolveEventByParam(param);
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
@@ -184,7 +232,7 @@ eventsRouter.get('/:id', async (c) => {
.from(tickets) .from(tickets)
.where( .where(
and( and(
eq((tickets as any).eventId, id), eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` 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 // Convert data for database compatibility
const dbData = convertBooleansForDb(data); 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 = { const newEvent = {
id, id,
...dbData, ...dbData,
slug,
startDatetime: toDbDateTz(data.startDatetime, tz), startDatetime: toDbDateTz(data.startDatetime, tz),
endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null, endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null,
createdAt: now, createdAt: now,
@@ -351,7 +404,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
const id = c.req.param('id'); const id = c.req.param('id');
const data = c.req.valid('json'); const data = c.req.valid('json');
const existing = await dbGet( const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id)) (db as any).select().from(events).where(eq((events as any).id, id))
); );
if (!existing) { if (!existing) {
@@ -362,6 +415,8 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
const tz = await getSiteTimezone(); const tz = await getSiteTimezone();
// Convert data for database compatibility // Convert data for database compatibility
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now }; const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
// Slug changes are handled explicitly below to manage aliases
delete updateData.slug;
// Convert datetime fields if present // Convert datetime fields if present
if (data.startDatetime) { if (data.startDatetime) {
updateData.startDatetime = toDbDateTz(data.startDatetime, tz); 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; 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) await (db as any)
.update(events) .update(events)
.set(updateData) .set(updateData)
@@ -429,6 +518,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Delete event payment overrides // Delete event payment overrides
await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id)); 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) // Set eventId to null on email logs (they reference this event but can exist without it)
await (db as any) await (db as any)
.update(emailLogs) .update(emailLogs)
@@ -471,11 +563,15 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
const now = getNow(); const now = getNow();
const newId = generateId(); 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 // Create a copy with modified title and draft status
const duplicatedEvent = { const duplicatedEvent = {
id: newId, id: newId,
title: `${existing.title} (Copy)`, slug,
title: duplicatedTitle,
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null, titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
description: existing.description, description: existing.description,
descriptionEs: existing.descriptionEs, 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); 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<any>(
(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<any>(
(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<any>(
(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; export default eventsRouter;

View File

@@ -166,7 +166,7 @@ export default function BookingPage() {
const soldOut = bookedCount >= capacity; const soldOut = bookedCount >= capacity;
if (soldOut) { if (soldOut) {
toast.error(t('events.details.soldOut')); toast.error(t('events.details.soldOut'));
router.push(`/events/${eventRes.event.id}`); router.push(`/events/${eventRes.event.slug}`);
return; return;
} }
@@ -228,6 +228,12 @@ export default function BookingPage() {
} }
}, [agreedToTerms, termsError]); }, [agreedToTerms, termsError]);
// Scroll to top when moving between booking steps (esp. mobile, where
// the submit button sits at the bottom of a long form)
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [step]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
@@ -1043,7 +1049,7 @@ export default function BookingPage() {
<div className="section-padding bg-secondary-gray min-h-screen"> <div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl"> <div className="container-page max-w-2xl">
<Link <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" className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
> >
<ArrowLeftIcon className="w-4 h-4" /> <ArrowLeftIcon className="w-4 h-4" />
@@ -1370,7 +1376,7 @@ export default function BookingPage() {
> >
{t('booking.form.termsAgreePart1')} {t('booking.form.termsAgreePart1')}
<Link <Link
href="/legal/terms-policy" href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline" className="text-secondary-blue hover:text-brand-navy underline"
@@ -1379,7 +1385,7 @@ export default function BookingPage() {
</Link> </Link>
{t('booking.form.termsAgreePart2')} {t('booking.form.termsAgreePart2')}
<Link <Link
href="/legal/privacy-policy" href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline" className="text-secondary-blue hover:text-brand-navy underline"

View File

@@ -69,7 +69,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
} }
return ( 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="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"> <div className="flex flex-col md:flex-row">
{/* Banner */} {/* Banner */}

View File

@@ -23,6 +23,8 @@ interface EventDetailClientProps {
initialEvent: Event; initialEvent: Event;
} }
const MAX_TICKETS_PER_PERSON = 5;
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) { export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [event, setEvent] = useState<Event>(initialEvent); const [event, setEvent] = useState<Event>(initialEvent);
@@ -44,7 +46,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
// Spots left: never negative; sold out when confirmed >= capacity // Spots left: never negative; sold out when confirmed >= capacity
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft); const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
useEffect(() => {
if (maxTickets > 0) {
setTicketQuantity((q) => Math.min(q, maxTickets));
}
}, [maxTickets]);
const decreaseQuantity = () => { const decreaseQuantity = () => {
setTicketQuantity(prev => Math.max(1, prev - 1)); setTicketQuantity(prev => Math.max(1, prev - 1));

View File

@@ -1,5 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound, permanentRedirect } from 'next/navigation';
import EventDetailClient from './EventDetailClient'; import EventDetailClient from './EventDetailClient';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; 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 { interface Event {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
description: string; description: string;
@@ -68,7 +69,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
title, title,
description, description,
type: 'website', type: 'website',
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }], images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }],
}, },
twitter: { twitter: {
@@ -78,7 +79,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
images: [imageUrl], images: [imageUrl],
}, },
alternates: { 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 availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
? 'https://schema.org/InStock' ? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut', : 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(), validFrom: new Date().toISOString(),
}, },
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, 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(); 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); const jsonLd = generateEventJsonLd(event);
return ( return (
@@ -142,7 +148,7 @@ export default async function EventDetailPage({ params }: { params: { id: string
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => ( {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"> <Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */} {/* Event banner */}
{event.bannerUrl ? ( {event.bannerUrl ? (

View File

@@ -68,6 +68,8 @@ export default async function LegalPage({ params, searchParams }: PageProps) {
return ( return (
<LegalPageLayout <LegalPageLayout
slug={resolvedParams.slug}
initialLocale={locale}
title={legalPage.title} title={legalPage.title}
content={legalPage.content} content={legalPage.content}
lastUpdated={legalPage.lastUpdated} lastUpdated={legalPage.lastUpdated}

View File

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

View File

@@ -22,6 +22,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
XMarkIcon, XMarkIcon,
ArrowPathIcon, ArrowPathIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -55,6 +56,9 @@ export default function AdminEmailsPage() {
const [logsOffset, setLogsOffset] = useState(0); const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0); const [logsTotal, setLogsTotal] = useState(0);
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all'); const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
const [logsSearch, setLogsSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [logsEventFilter, setLogsEventFilter] = useState('');
const [resendingLogId, setResendingLogId] = useState<string | null>(null); const [resendingLogId, setResendingLogId] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null); const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
@@ -214,11 +218,20 @@ export default function AdminEmailsPage() {
} }
}; };
useEffect(() => {
const handle = setTimeout(() => setDebouncedSearch(logsSearch), 300);
return () => clearTimeout(handle);
}, [logsSearch]);
useEffect(() => {
setLogsOffset(0);
}, [debouncedSearch, logsEventFilter]);
useEffect(() => { useEffect(() => {
if (activeTab === 'logs') { if (activeTab === 'logs') {
loadLogs(); loadLogs();
} }
}, [activeTab, logsOffset, logsSubTab]); }, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -241,6 +254,8 @@ export default function AdminEmailsPage() {
limit: 20, limit: 20,
offset: logsOffset, offset: logsOffset,
...(logsSubTab === 'failed' ? { status: 'failed' } : {}), ...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(logsEventFilter ? { eventId: logsEventFilter } : {}),
}); });
setLogs(res.logs); setLogs(res.logs);
setLogsTotal(res.pagination.total); setLogsTotal(res.pagination.total);
@@ -757,6 +772,41 @@ export default function AdminEmailsPage() {
</nav> </nav>
</div> </div>
{/* Filters: search + event */}
<div className="flex flex-col md:flex-row gap-3 mb-4">
<div className="relative flex-1">
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={logsSearch}
onChange={(e) => setLogsSearch(e.target.value)}
placeholder="Search by recipient or subject..."
className="w-full pl-10 pr-10 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
{logsSearch && (
<button
onClick={() => setLogsSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-btn"
title="Clear search"
>
<XMarkIcon className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
<select
value={logsEventFilter}
onChange={(e) => setLogsEventFilter(e.target.value)}
className="w-full md:w-64 px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="">All events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title}
</option>
))}
</select>
</div>
{/* Desktop: Table */} {/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block"> <Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -772,7 +822,7 @@ export default function AdminEmailsPage() {
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{logs.length === 0 ? ( {logs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr> <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={log.id} className="hover:bg-gray-50">
@@ -829,7 +879,7 @@ export default function AdminEmailsPage() {
{/* Mobile: Card List */} {/* Mobile: Card List */}
<div className="md:hidden space-y-2"> <div className="md:hidden space-y-2">
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div> <div className="text-center py-10 text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}> <Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>

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 ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'} {showStats ? 'Hide Stats' : 'Show Stats'}
</Button> </Button>
<Link href={`/events/${event.id}`} target="_blank"> <Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" /> <EyeIcon className="w-4 h-4 mr-1.5" />
View Public View Public
@@ -618,7 +618,7 @@ export default function AdminEventDetailPage() {
</button> </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 <EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem> </DropdownItem>
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> <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 [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null); const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
title: string; title: string;
titleEs: string; titleEs: string;
slug: string;
description: string; description: string;
descriptionEs: string; descriptionEs: string;
shortDescription: string; shortDescription: string;
@@ -49,6 +51,7 @@ export default function AdminEventsPage() {
}>({ }>({
title: '', title: '',
titleEs: '', titleEs: '',
slug: '',
description: '', description: '',
descriptionEs: '', descriptionEs: '',
shortDescription: '', shortDescription: '',
@@ -113,8 +116,9 @@ export default function AdminEventsPage() {
}; };
const resetForm = () => { const resetForm = () => {
setSlugAliases([]);
setFormData({ setFormData({
title: '', titleEs: '', description: '', descriptionEs: '', title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '', shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const, 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')}`; 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) => { const handleEdit = (event: Event) => {
setFormData({ setFormData({
title: event.title, titleEs: event.titleEs || '', title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
description: event.description, descriptionEs: event.descriptionEs || '', description: event.description, descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '', shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime), startDatetime: isoToLocalDatetime(event.startDatetime),
@@ -154,6 +179,7 @@ export default function AdminEventsPage() {
}); });
setEditingEvent(event); setEditingEvent(event);
setShowForm(true); setShowForm(true);
loadSlugAliases(event.id);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -170,7 +196,7 @@ export default function AdminEventsPage() {
setSaving(false); setSaving(false);
return; return;
} }
const eventData = { const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined, title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined,
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined, shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
@@ -183,6 +209,8 @@ export default function AdminEventsPage() {
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
}; };
if (editingEvent) { 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); await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated'); toast.success('Event updated');
} else { } else {
@@ -299,6 +327,38 @@ export default function AdminEventsPage() {
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} /> onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
</div> </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> <div>
<label className="block text-sm font-medium mb-1">Description (English)</label> <label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea value={formData.description} <textarea value={formData.description}

View File

@@ -127,11 +127,11 @@
} }
.ProseMirror ul { .ProseMirror ul {
@apply list-disc list-inside my-3; @apply list-disc list-outside pl-6 my-3;
} }
.ProseMirror ol { .ProseMirror ol {
@apply list-decimal list-inside my-3; @apply list-decimal list-outside pl-6 my-3;
} }
.ProseMirror li { .ProseMirror li {

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 className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div> </div>
) : nextEvent ? ( ) : 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"> <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"> <h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title} {locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}

View File

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

View File

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

View File

@@ -108,7 +108,7 @@ export default function Footer() {
{legalLinks.map((link) => ( {legalLinks.map((link) => (
<Link <Link
key={link.slug} key={link.slug}
href={`/legal/${link.slug}`} href={`/legal/${link.slug}${locale === 'es' ? '?locale=es' : ''}`}
className="hover:opacity-70 transition-colors text-sm" className="hover:opacity-70 transition-colors text-sm"
style={{ color: '#002F44' }} style={{ color: '#002F44' }}
> >

View File

@@ -1,17 +1,74 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline'; import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi } from '@/lib/api';
interface LegalPageLayoutProps { interface LegalPageLayoutProps {
slug: string;
initialLocale: 'en' | 'es';
title: string; title: string;
content: string; content: string;
lastUpdated?: string; lastUpdated?: string;
} }
export default function LegalPageLayout({ title, content, lastUpdated }: LegalPageLayoutProps) { function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string | undefined {
const match = contentMarkdown?.match(/Last updated:\s*(.+)/i);
return match ? match[1].trim() : updatedAt;
}
export default function LegalPageLayout({
slug,
initialLocale,
title: initialTitle,
content: initialContent,
lastUpdated: initialLastUpdated,
}: LegalPageLayoutProps) {
const { locale, t } = useLanguage();
const [title, setTitle] = useState(initialTitle);
const [content, setContent] = useState(initialContent);
const [lastUpdated, setLastUpdated] = useState(initialLastUpdated);
const [loadedLocale, setLoadedLocale] = useState(initialLocale);
useEffect(() => {
if (locale === loadedLocale) {
return;
}
// Returning to the server-rendered language: restore SSR content without a fetch
if (locale === initialLocale) {
setTitle(initialTitle);
setContent(initialContent);
setLastUpdated(initialLastUpdated);
setLoadedLocale(initialLocale);
return;
}
let cancelled = false;
legalPagesApi
.getBySlug(slug, locale)
.then(({ page }) => {
if (cancelled || !page) {
return;
}
setTitle(page.title);
setContent(page.contentMarkdown);
setLastUpdated(extractLastUpdated(page.contentMarkdown, page.updatedAt));
setLoadedLocale(locale);
})
.catch(() => {
// Keep the server-rendered content if the re-fetch fails
});
return () => {
cancelled = true;
};
}, [locale, loadedLocale, initialLocale, slug, initialTitle, initialContent, initialLastUpdated]);
return ( return (
<div className="section-padding"> <div className="section-padding">
<div className="container-page max-w-4xl"> <div className="container-page max-w-4xl">
@@ -21,7 +78,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8" className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
> >
<ArrowLeftIcon className="w-4 h-4 mr-2" /> <ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Home {t('legalPage.backToHome')}
</Link> </Link>
{/* Title */} {/* Title */}
@@ -31,7 +88,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
</h1> </h1>
{lastUpdated && lastUpdated !== '[Insert Date]' && ( {lastUpdated && lastUpdated !== '[Insert Date]' && (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Last updated: {lastUpdated} {t('legalPage.lastUpdated', { date: lastUpdated })}
</p> </p>
)} )}
</div> </div>
@@ -70,12 +127,12 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
), ),
// Style lists // Style lists
ul: ({ children }) => ( ul: ({ children }) => (
<ul className="list-disc list-inside space-y-2 mb-4 text-gray-700 ml-4"> <ul className="list-disc list-outside space-y-2 mb-4 text-gray-700 pl-6">
{children} {children}
</ul> </ul>
), ),
ol: ({ children }) => ( ol: ({ children }) => (
<ol className="list-decimal list-inside space-y-2 mb-4 text-gray-700 ml-4"> <ol className="list-decimal list-outside space-y-2 mb-4 text-gray-700 pl-6">
{children} {children}
</ol> </ol>
), ),
@@ -182,7 +239,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm" className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
> >
Back to top {t('legalPage.backToTop')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -322,6 +322,11 @@
"refund": "Refund Policy" "refund": "Refund Policy"
} }
}, },
"legalPage": {
"backToHome": "Back to Home",
"lastUpdated": "Last updated: {date}",
"backToTop": "Back to top"
},
"linktree": { "linktree": {
"tagline": "Language Exchange Community", "tagline": "Language Exchange Community",
"nextEvent": "Next Event", "nextEvent": "Next Event",

View File

@@ -322,6 +322,11 @@
"refund": "Política de Reembolso" "refund": "Política de Reembolso"
} }
}, },
"legalPage": {
"backToHome": "Volver al inicio",
"lastUpdated": "Última actualización: {date}",
"backToTop": "Volver arriba"
},
"linktree": { "linktree": {
"tagline": "Comunidad de Intercambio de Idiomas", "tagline": "Comunidad de Intercambio de Idiomas",
"nextEvent": "Próximo Evento", "nextEvent": "Próximo Evento",

View File

@@ -67,6 +67,12 @@ export const eventsApi = {
duplicate: (id: string) => duplicate: (id: string) =>
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }), 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 // Tickets API
@@ -496,10 +502,11 @@ export const emailsApi = {
}), }),
// Logs // Logs
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => { getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId); if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.limit) query.set('limit', params.limit.toString()); if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString()); if (params?.offset) query.set('offset', params.offset.toString());
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`); return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
@@ -524,6 +531,7 @@ export const emailsApi = {
// Types // Types
export interface Event { export interface Event {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
description: string; description: string;