Compare commits
3 Commits
backup9
...
tpago-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4af38e8a | ||
|
|
1b2463f4bc | ||
|
|
d09c87a5a5 |
@@ -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,
|
||||||
@@ -241,6 +259,10 @@ async function migrate() {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
tpago_link TEXT,
|
tpago_link TEXT,
|
||||||
|
tpago_link_2 TEXT,
|
||||||
|
tpago_link_3 TEXT,
|
||||||
|
tpago_link_4 TEXT,
|
||||||
|
tpago_link_5 TEXT,
|
||||||
tpago_instructions TEXT,
|
tpago_instructions TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
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`);
|
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 */ }
|
} 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
|
// Event payment overrides table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||||
@@ -272,6 +301,10 @@ async function migrate() {
|
|||||||
event_id TEXT NOT NULL REFERENCES events(id),
|
event_id TEXT NOT NULL REFERENCES events(id),
|
||||||
tpago_enabled INTEGER,
|
tpago_enabled INTEGER,
|
||||||
tpago_link TEXT,
|
tpago_link TEXT,
|
||||||
|
tpago_link_2 TEXT,
|
||||||
|
tpago_link_3 TEXT,
|
||||||
|
tpago_link_4 TEXT,
|
||||||
|
tpago_link_5 TEXT,
|
||||||
tpago_instructions TEXT,
|
tpago_instructions TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER,
|
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`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id TEXT PRIMARY KEY,
|
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'`);
|
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,
|
||||||
@@ -667,6 +724,10 @@ async function migrate() {
|
|||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
tpago_link VARCHAR(500),
|
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 TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
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`);
|
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 */ }
|
} 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`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
event_id UUID NOT NULL REFERENCES events(id),
|
event_id UUID NOT NULL REFERENCES events(id),
|
||||||
tpago_enabled INTEGER,
|
tpago_enabled INTEGER,
|
||||||
tpago_link VARCHAR(500),
|
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 TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER,
|
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`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id UUID PRIMARY KEY,
|
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!');
|
console.log('Migrations completed successfully!');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -127,6 +135,10 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
|||||||
// TPago configuration
|
// TPago configuration
|
||||||
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
|
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
tpagoLink: text('tpago_link'),
|
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'),
|
tpagoInstructions: text('tpago_instructions'),
|
||||||
tpagoInstructionsEs: text('tpago_instructions_es'),
|
tpagoInstructionsEs: text('tpago_instructions_es'),
|
||||||
// Bank Transfer configuration
|
// Bank Transfer configuration
|
||||||
@@ -158,6 +170,10 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
|
|||||||
// Override flags (null means use global)
|
// Override flags (null means use global)
|
||||||
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
|
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
|
||||||
tpagoLink: text('tpago_link'),
|
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'),
|
tpagoInstructions: text('tpago_instructions'),
|
||||||
tpagoInstructionsEs: text('tpago_instructions_es'),
|
tpagoInstructionsEs: text('tpago_instructions_es'),
|
||||||
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
|
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
|
||||||
@@ -387,6 +403,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 +425,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
|
||||||
@@ -451,6 +475,10 @@ export const pgPaymentOptions = pgTable('payment_options', {
|
|||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
|
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
|
||||||
tpagoLink: varchar('tpago_link', { length: 500 }),
|
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'),
|
tpagoInstructions: pgText('tpago_instructions'),
|
||||||
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
||||||
bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0),
|
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),
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
tpagoEnabled: pgInteger('tpago_enabled'),
|
tpagoEnabled: pgInteger('tpago_enabled'),
|
||||||
tpagoLink: varchar('tpago_link', { length: 500 }),
|
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'),
|
tpagoInstructions: pgText('tpago_instructions'),
|
||||||
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
||||||
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
|
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
|
||||||
@@ -649,6 +681,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;
|
||||||
|
|||||||
@@ -748,6 +748,10 @@ export const emailService = {
|
|||||||
const defaults = {
|
const defaults = {
|
||||||
tpagoEnabled: false,
|
tpagoEnabled: false,
|
||||||
tpagoLink: null,
|
tpagoLink: null,
|
||||||
|
tpagoLink2: null,
|
||||||
|
tpagoLink3: null,
|
||||||
|
tpagoLink4: null,
|
||||||
|
tpagoLink5: null,
|
||||||
tpagoInstructions: null,
|
tpagoInstructions: null,
|
||||||
tpagoInstructionsEs: null,
|
tpagoInstructionsEs: null,
|
||||||
bankTransferEnabled: false,
|
bankTransferEnabled: false,
|
||||||
@@ -766,6 +770,10 @@ export const emailService = {
|
|||||||
return {
|
return {
|
||||||
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
||||||
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
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,
|
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
||||||
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
||||||
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
||||||
@@ -885,7 +893,9 @@ export const emailService = {
|
|||||||
|
|
||||||
// Add payment-method specific variables
|
// Add payment-method specific variables
|
||||||
if (payment.provider === 'tpago') {
|
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 {
|
} else {
|
||||||
// Bank transfer
|
// Bank transfer
|
||||||
variables.bankName = paymentConfig.bankName || '';
|
variables.bankName = paymentConfig.bankName || '';
|
||||||
|
|||||||
27
backend/src/lib/slugify.ts
Normal file
27
backend/src/lib/slugify.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => {
|
|||||||
const updatePaymentOptionsSchema = z.object({
|
const updatePaymentOptionsSchema = z.object({
|
||||||
tpagoEnabled: booleanOrNumber.optional(),
|
tpagoEnabled: booleanOrNumber.optional(),
|
||||||
tpagoLink: z.string().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(),
|
tpagoInstructions: z.string().optional().nullable(),
|
||||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||||
bankTransferEnabled: booleanOrNumber.optional(),
|
bankTransferEnabled: booleanOrNumber.optional(),
|
||||||
@@ -40,6 +44,10 @@ const updatePaymentOptionsSchema = z.object({
|
|||||||
const updateEventOverridesSchema = z.object({
|
const updateEventOverridesSchema = z.object({
|
||||||
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
||||||
tpagoLink: z.string().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(),
|
tpagoInstructions: z.string().optional().nullable(),
|
||||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||||
bankTransferEnabled: booleanOrNumber.optional().nullable(),
|
bankTransferEnabled: booleanOrNumber.optional().nullable(),
|
||||||
@@ -68,6 +76,10 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
paymentOptions: {
|
paymentOptions: {
|
||||||
tpagoEnabled: false,
|
tpagoEnabled: false,
|
||||||
tpagoLink: null,
|
tpagoLink: null,
|
||||||
|
tpagoLink2: null,
|
||||||
|
tpagoLink3: null,
|
||||||
|
tpagoLink4: null,
|
||||||
|
tpagoLink5: null,
|
||||||
tpagoInstructions: null,
|
tpagoInstructions: null,
|
||||||
tpagoInstructionsEs: null,
|
tpagoInstructionsEs: null,
|
||||||
bankTransferEnabled: false,
|
bankTransferEnabled: false,
|
||||||
@@ -171,6 +183,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
const defaults = {
|
const defaults = {
|
||||||
tpagoEnabled: false,
|
tpagoEnabled: false,
|
||||||
tpagoLink: null,
|
tpagoLink: null,
|
||||||
|
tpagoLink2: null,
|
||||||
|
tpagoLink3: null,
|
||||||
|
tpagoLink4: null,
|
||||||
|
tpagoLink5: null,
|
||||||
tpagoInstructions: null,
|
tpagoInstructions: null,
|
||||||
tpagoInstructionsEs: null,
|
tpagoInstructionsEs: null,
|
||||||
bankTransferEnabled: false,
|
bankTransferEnabled: false,
|
||||||
@@ -193,6 +209,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
const merged = {
|
const merged = {
|
||||||
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
||||||
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
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,
|
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
||||||
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
||||||
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
||||||
|
|||||||
@@ -631,11 +631,21 @@ ticketsRouter.get('/:id', async (c) => {
|
|||||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, id))
|
(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({
|
return c.json({
|
||||||
ticket: {
|
ticket: {
|
||||||
...ticket,
|
...ticket,
|
||||||
event,
|
event,
|
||||||
payment,
|
payment,
|
||||||
|
bookingTicketCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Link from 'next/link';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
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 Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
@@ -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');
|
||||||
|
|
||||||
@@ -659,6 +665,7 @@ export default function BookingPage() {
|
|||||||
const isTpago = bookingResult.paymentMethod === 'tpago';
|
const isTpago = bookingResult.paymentMethod === 'tpago';
|
||||||
const ticketCount = bookingResult.ticketCount || 1;
|
const ticketCount = bookingResult.ticketCount || 1;
|
||||||
const totalAmount = (event?.price || 0) * ticketCount;
|
const totalAmount = (event?.price || 0) * ticketCount;
|
||||||
|
const tpagoLink = getTpagoLink(paymentConfig, ticketCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding">
|
<div className="section-padding">
|
||||||
@@ -749,9 +756,9 @@ export default function BookingPage() {
|
|||||||
<h3 className="font-semibold text-gray-900">
|
<h3 className="font-semibold text-gray-900">
|
||||||
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
||||||
</h3>
|
</h3>
|
||||||
{paymentConfig.tpagoLink && (
|
{tpagoLink && (
|
||||||
<a
|
<a
|
||||||
href={paymentConfig.tpagoLink}
|
href={tpagoLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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="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" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
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 Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import {
|
||||||
@@ -305,6 +305,7 @@ export default function BookingPaymentPage() {
|
|||||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||||
const isTpago = ticket.payment?.provider === 'tpago';
|
const isTpago = ticket.payment?.provider === 'tpago';
|
||||||
|
const tpagoLink = getTpagoLink(paymentConfig, ticket.bookingTicketCount || 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding">
|
<div className="section-padding">
|
||||||
@@ -418,9 +419,9 @@ export default function BookingPaymentPage() {
|
|||||||
<h3 className="font-semibold text-gray-900">
|
<h3 className="font-semibold text-gray-900">
|
||||||
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
||||||
</h3>
|
</h3>
|
||||||
{paymentConfig.tpagoLink && (
|
{tpagoLink && (
|
||||||
<a
|
<a
|
||||||
href={paymentConfig.tpagoLink}
|
href={tpagoLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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"
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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); }}>
|
||||||
@@ -1519,16 +1519,33 @@ export default function AdminEventDetailPage() {
|
|||||||
<div className="space-y-3 pt-3 border-t">
|
<div className="space-y-3 pt-3 border-t">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<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>
|
</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
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={paymentOverrides.tpagoLink ?? ''}
|
value={(paymentOverrides[key] as string | null) ?? ''}
|
||||||
onChange={(e) => updatePaymentOverride('tpagoLink', e.target.value || null)}
|
onChange={(e) => updatePaymentOverride(key, (e.target.value || null) as any)}
|
||||||
placeholder={globalPaymentOptions?.tpagoLink || 'https://www.tpago.com.py/links?alias=...'}
|
placeholder={(globalPaymentOptions?.[key] as string | null) || '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"
|
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>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export default function PaymentOptionsPage() {
|
|||||||
const [options, setOptions] = useState<PaymentOptionsConfig>({
|
const [options, setOptions] = useState<PaymentOptionsConfig>({
|
||||||
tpagoEnabled: false,
|
tpagoEnabled: false,
|
||||||
tpagoLink: null,
|
tpagoLink: null,
|
||||||
|
tpagoLink2: null,
|
||||||
|
tpagoLink3: null,
|
||||||
|
tpagoLink4: null,
|
||||||
|
tpagoLink5: null,
|
||||||
tpagoInstructions: null,
|
tpagoInstructions: null,
|
||||||
tpagoInstructionsEs: null,
|
tpagoInstructionsEs: null,
|
||||||
bankTransferEnabled: false,
|
bankTransferEnabled: false,
|
||||||
@@ -140,14 +144,32 @@ export default function PaymentOptionsPage() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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>
|
</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
|
<Input
|
||||||
value={options.tpagoLink || ''}
|
value={(options[key] as string | null) || ''}
|
||||||
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
|
onChange={(e) => updateOption(key, (e.target.value || null) as any)}
|
||||||
placeholder="https://www.tpago.com.py/links?alias=..."
|
placeholder="https://www.tpago.com.py/links?alias=..."
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -525,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;
|
||||||
@@ -552,6 +559,7 @@ export interface Event {
|
|||||||
export interface Ticket {
|
export interface Ticket {
|
||||||
id: string;
|
id: string;
|
||||||
bookingId?: string; // Groups multiple tickets from same booking
|
bookingId?: string; // Groups multiple tickets from same booking
|
||||||
|
bookingTicketCount?: number; // Total tickets in the booking (for per-quantity payment links)
|
||||||
userId: string;
|
userId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
attendeeFirstName: string;
|
attendeeFirstName: string;
|
||||||
@@ -666,6 +674,10 @@ export interface PaymentWithDetails extends Payment {
|
|||||||
export interface PaymentOptionsConfig {
|
export interface PaymentOptionsConfig {
|
||||||
tpagoEnabled: boolean;
|
tpagoEnabled: boolean;
|
||||||
tpagoLink?: string | null;
|
tpagoLink?: string | null;
|
||||||
|
tpagoLink2?: string | null;
|
||||||
|
tpagoLink3?: string | null;
|
||||||
|
tpagoLink4?: string | null;
|
||||||
|
tpagoLink5?: string | null;
|
||||||
tpagoInstructions?: string | null;
|
tpagoInstructions?: string | null;
|
||||||
tpagoInstructionsEs?: string | null;
|
tpagoInstructionsEs?: string | null;
|
||||||
bankTransferEnabled: boolean;
|
bankTransferEnabled: boolean;
|
||||||
|
|||||||
@@ -165,3 +165,31 @@ export function formatPrice(price: number, currency: string = 'PYG'): string {
|
|||||||
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||||
return formatPrice(amount, currency);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user