3 Commits

Author SHA1 Message Date
Michilis
fc4af38e8a Add per-quantity TPago payment links for multi-ticket checkout.
Each ticket quantity (1-5) can have its own TPago link so checkout, emails, and admin config use the correct fixed-amount URL.
2026-06-18 23:59:17 +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
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
21 changed files with 562 additions and 54 deletions

View File

@@ -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,
@@ -241,6 +259,10 @@ async function migrate() {
id TEXT PRIMARY KEY,
tpago_enabled INTEGER NOT NULL DEFAULT 0,
tpago_link TEXT,
tpago_link_2 TEXT,
tpago_link_3 TEXT,
tpago_link_4 TEXT,
tpago_link_5 TEXT,
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
@@ -265,6 +287,13 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Add per-quantity TPago link columns to payment_options if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).run(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} TEXT`));
} catch (e) { /* column may already exist */ }
}
// Event payment overrides table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS event_payment_overrides (
@@ -272,6 +301,10 @@ async function migrate() {
event_id TEXT NOT NULL REFERENCES events(id),
tpago_enabled INTEGER,
tpago_link TEXT,
tpago_link_2 TEXT,
tpago_link_3 TEXT,
tpago_link_4 TEXT,
tpago_link_5 TEXT,
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER,
@@ -291,6 +324,13 @@ async function migrate() {
)
`);
// Add per-quantity TPago link columns to event_payment_overrides if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).run(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} TEXT`));
} catch (e) { /* column may already exist */ }
}
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
@@ -579,6 +619,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,
@@ -667,6 +724,10 @@ async function migrate() {
id UUID PRIMARY KEY,
tpago_enabled INTEGER NOT NULL DEFAULT 0,
tpago_link VARCHAR(500),
tpago_link_2 VARCHAR(500),
tpago_link_3 VARCHAR(500),
tpago_link_4 VARCHAR(500),
tpago_link_5 VARCHAR(500),
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
@@ -691,12 +752,23 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Add per-quantity TPago link columns to payment_options if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).execute(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} VARCHAR(500)`));
} catch (e) { /* column may already exist */ }
}
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS event_payment_overrides (
id UUID PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id),
tpago_enabled INTEGER,
tpago_link VARCHAR(500),
tpago_link_2 VARCHAR(500),
tpago_link_3 VARCHAR(500),
tpago_link_4 VARCHAR(500),
tpago_link_5 VARCHAR(500),
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER,
@@ -716,6 +788,13 @@ async function migrate() {
)
`);
// Add per-quantity TPago link columns to event_payment_overrides if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).execute(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} VARCHAR(500)`));
} catch (e) { /* column may already exist */ }
}
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS contacts (
id UUID PRIMARY KEY,
@@ -895,6 +974,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);
}

View File

@@ -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
@@ -127,6 +135,10 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
// TPago configuration
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
tpagoLink: text('tpago_link'),
tpagoLink2: text('tpago_link_2'),
tpagoLink3: text('tpago_link_3'),
tpagoLink4: text('tpago_link_4'),
tpagoLink5: text('tpago_link_5'),
tpagoInstructions: text('tpago_instructions'),
tpagoInstructionsEs: text('tpago_instructions_es'),
// Bank Transfer configuration
@@ -158,6 +170,10 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
// Override flags (null means use global)
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
tpagoLink: text('tpago_link'),
tpagoLink2: text('tpago_link_2'),
tpagoLink3: text('tpago_link_3'),
tpagoLink4: text('tpago_link_4'),
tpagoLink5: text('tpago_link_5'),
tpagoInstructions: text('tpago_instructions'),
tpagoInstructionsEs: text('tpago_instructions_es'),
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
@@ -387,6 +403,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 +425,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
@@ -451,6 +475,10 @@ export const pgPaymentOptions = pgTable('payment_options', {
id: uuid('id').primaryKey(),
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
tpagoLink: varchar('tpago_link', { length: 500 }),
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
tpagoInstructions: pgText('tpago_instructions'),
tpagoInstructionsEs: pgText('tpago_instructions_es'),
bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0),
@@ -476,6 +504,10 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
tpagoEnabled: pgInteger('tpago_enabled'),
tpagoLink: varchar('tpago_link', { length: 500 }),
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
tpagoInstructions: pgText('tpago_instructions'),
tpagoInstructionsEs: pgText('tpago_instructions_es'),
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
@@ -649,6 +681,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;

View File

@@ -748,6 +748,10 @@ export const emailService = {
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
@@ -766,6 +770,10 @@ export const emailService = {
return {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
@@ -885,7 +893,9 @@ export const emailService = {
// Add payment-method specific variables
if (payment.provider === 'tpago') {
variables.tpagoLink = paymentConfig.tpagoLink || '';
// Select the TPago link matching the number of tickets (1-5), falling back to the base link
const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`;
variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || '';
} else {
// Bank transfer
variables.bankName = paymentConfig.bankName || '';

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,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<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
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<any>(
(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<any>(
(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<string, any> = { ...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<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;

View File

@@ -18,6 +18,10 @@ const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => {
const updatePaymentOptionsSchema = z.object({
tpagoEnabled: booleanOrNumber.optional(),
tpagoLink: z.string().optional().nullable(),
tpagoLink2: z.string().optional().nullable(),
tpagoLink3: z.string().optional().nullable(),
tpagoLink4: z.string().optional().nullable(),
tpagoLink5: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: booleanOrNumber.optional(),
@@ -40,6 +44,10 @@ const updatePaymentOptionsSchema = z.object({
const updateEventOverridesSchema = z.object({
tpagoEnabled: booleanOrNumber.optional().nullable(),
tpagoLink: z.string().optional().nullable(),
tpagoLink2: z.string().optional().nullable(),
tpagoLink3: z.string().optional().nullable(),
tpagoLink4: z.string().optional().nullable(),
tpagoLink5: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: booleanOrNumber.optional().nullable(),
@@ -68,6 +76,10 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
paymentOptions: {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
@@ -171,6 +183,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
@@ -193,6 +209,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
const merged = {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,

View File

@@ -631,11 +631,21 @@ ticketsRouter.get('/:id', async (c) => {
(db as any).select().from(payments).where(eq((payments as any).ticketId, id))
);
// Count how many tickets belong to this booking (for per-quantity payment links)
let bookingTicketCount = 1;
if (ticket.bookingId) {
const bookingTickets = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
bookingTicketCount = bookingTickets.length || 1;
}
return c.json({
ticket: {
...ticket,
event,
payment,
bookingTicketCount,
},
});
});

View File

@@ -6,7 +6,7 @@ import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -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;
}
@@ -228,6 +228,12 @@ export default function BookingPage() {
}
}, [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 fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
@@ -659,6 +665,7 @@ export default function BookingPage() {
const isTpago = bookingResult.paymentMethod === 'tpago';
const ticketCount = bookingResult.ticketCount || 1;
const totalAmount = (event?.price || 0) * ticketCount;
const tpagoLink = getTpagoLink(paymentConfig, ticketCount);
return (
<div className="section-padding">
@@ -749,9 +756,9 @@ export default function BookingPage() {
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
</h3>
{paymentConfig.tpagoLink && (
{tpagoLink && (
<a
href={paymentConfig.tpagoLink}
href={tpagoLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
@@ -1043,7 +1050,7 @@ export default function BookingPage() {
<div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl">
<Link
href={`/events/${event.id}`}
href={`/events/${event.slug}`}
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
>
<ArrowLeftIcon className="w-4 h-4" />

View File

@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
@@ -305,6 +305,7 @@ export default function BookingPaymentPage() {
if (step === 'manual_payment' && ticket && paymentConfig) {
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
const isTpago = ticket.payment?.provider === 'tpago';
const tpagoLink = getTpagoLink(paymentConfig, ticket.bookingTicketCount || 1);
return (
<div className="section-padding">
@@ -418,9 +419,9 @@ export default function BookingPaymentPage() {
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
</h3>
{paymentConfig.tpagoLink && (
{tpagoLink && (
<a
href={paymentConfig.tpagoLink}
href={tpagoLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"

View File

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

View File

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

View File

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

View File

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

View File

@@ -594,7 +594,7 @@ export default function AdminEventDetailPage() {
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</Button>
<Link href={`/events/${event.id}`} target="_blank">
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
View Public
@@ -618,7 +618,7 @@ export default function AdminEventDetailPage() {
</button>
}
>
<DropdownItem onClick={() => { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem>
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
@@ -1519,16 +1519,33 @@ export default function AdminEventDetailPage() {
<div className="space-y-3 pt-3 border-t">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
</label>
<p className="text-[10px] text-gray-500 mb-2">
{locale === 'es'
? 'Cada enlace tiene un monto fijo. Un enlace distinto por cantidad de tickets.'
: 'Each link has a fixed amount. One link per ticket quantity.'}
</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5] as const).map((qty) => {
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
return (
<div key={qty} className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-600 w-20 flex-shrink-0">
{qty} {qty === 1 ? 'ticket' : 'tickets'}
</span>
<input
type="url"
value={paymentOverrides.tpagoLink ?? ''}
onChange={(e) => updatePaymentOverride('tpagoLink', e.target.value || null)}
placeholder={globalPaymentOptions?.tpagoLink || 'https://www.tpago.com.py/links?alias=...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
value={(paymentOverrides[key] as string | null) ?? ''}
onChange={(e) => updatePaymentOverride(key, (e.target.value || null) as any)}
placeholder={(globalPaymentOptions?.[key] as string | null) || 'https://www.tpago.com.py/links?alias=...'}
className="flex-1 px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>

View File

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

View File

@@ -26,6 +26,10 @@ export default function PaymentOptionsPage() {
const [options, setOptions] = useState<PaymentOptionsConfig>({
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
@@ -140,14 +144,32 @@ export default function PaymentOptionsPage() {
<div className="space-y-4 pt-4 border-t">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
</label>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Cada enlace tiene un monto fijo. Usá un enlace distinto para cada cantidad de tickets.'
: 'Each link has a fixed amount. Use a different link for each ticket quantity.'}
</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5] as const).map((qty) => {
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
return (
<div key={qty} className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600 w-24 flex-shrink-0">
{qty} {locale === 'es' ? (qty === 1 ? 'ticket' : 'tickets') : (qty === 1 ? 'ticket' : 'tickets')}
</span>
<Input
value={options.tpagoLink || ''}
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
value={(options[key] as string | null) || ''}
onChange={(e) => updateOption(key, (e.target.value || null) as any)}
placeholder="https://www.tpago.com.py/links?alias=..."
className="flex-1"
/>
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,12 @@ export const eventsApi = {
duplicate: (id: string) =>
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
getSlugAliases: (id: string) =>
fetchApi<{ aliases: { slug: string; createdAt: string }[] }>(`/api/events/${id}/slug-aliases`),
deleteSlugAlias: (id: string, slug: string) =>
fetchApi<{ message: string }>(`/api/events/${id}/slug-aliases/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
};
// Tickets API
@@ -525,6 +531,7 @@ export const emailsApi = {
// Types
export interface Event {
id: string;
slug: string;
title: string;
titleEs?: string;
description: string;
@@ -552,6 +559,7 @@ export interface Event {
export interface Ticket {
id: string;
bookingId?: string; // Groups multiple tickets from same booking
bookingTicketCount?: number; // Total tickets in the booking (for per-quantity payment links)
userId: string;
eventId: string;
attendeeFirstName: string;
@@ -666,6 +674,10 @@ export interface PaymentWithDetails extends Payment {
export interface PaymentOptionsConfig {
tpagoEnabled: boolean;
tpagoLink?: string | null;
tpagoLink2?: string | null;
tpagoLink3?: string | null;
tpagoLink4?: string | null;
tpagoLink5?: string | null;
tpagoInstructions?: string | null;
tpagoInstructionsEs?: string | null;
bankTransferEnabled: boolean;

View File

@@ -165,3 +165,31 @@ export function formatPrice(price: number, currency: string = 'PYG'): string {
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
return formatPrice(amount, currency);
}
// ---------------------------------------------------------------------------
// Payment helpers
// ---------------------------------------------------------------------------
type TpagoLinkConfig = {
tpagoLink?: string | null;
tpagoLink2?: string | null;
tpagoLink3?: string | null;
tpagoLink4?: string | null;
tpagoLink5?: string | null;
};
/**
* Select the TPago payment link that matches the number of tickets being
* purchased (1-5). Each link has a fixed amount baked in, so the quantity
* determines which one to use. Falls back to the base (1-ticket) link when a
* specific quantity link isn't configured.
*/
export function getTpagoLink(
config: TpagoLinkConfig | null | undefined,
ticketCount: number
): string | null {
if (!config) return null;
const count = Math.min(Math.max(1, Math.floor(ticketCount || 1)), 5);
const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig;
return config[key] || config.tpagoLink || null;
}