17 Commits

Author SHA1 Message Date
1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
50 changed files with 183 additions and 1315 deletions

2
.gitignore vendored
View File

@@ -37,8 +37,6 @@ backend/uploads/
# Tooling # Tooling
.turbo/ .turbo/
.cursor/ .cursor/
.agents/
skills-lock.json
.npm-cache/ .npm-cache/
# OS # OS

View File

@@ -1,7 +1,6 @@
import 'dotenv/config'; import 'dotenv/config';
import { db, dbAll, events } from './index.js'; import { db } from './index.js';
import { sql, eq } from 'drizzle-orm'; import { sql } 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}`);
@@ -112,23 +111,6 @@ 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,
@@ -191,11 +173,6 @@ async function migrate() {
try { try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`); await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migration: Add is_guest column to tickets
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries) // Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work // SQLite doesn't support altering column constraints, so we'll just ensure new entries work
@@ -259,10 +236,6 @@ 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,
@@ -287,13 +260,6 @@ 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 (
@@ -301,10 +267,6 @@ 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,
@@ -324,13 +286,6 @@ 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,
@@ -578,8 +533,8 @@ async function migrate() {
description_es TEXT, description_es TEXT,
short_description VARCHAR(300), short_description VARCHAR(300),
short_description_es VARCHAR(300), short_description_es VARCHAR(300),
start_datetime TIMESTAMPTZ NOT NULL, start_datetime TIMESTAMP NOT NULL,
end_datetime TIMESTAMPTZ, end_datetime TIMESTAMP,
location VARCHAR(500) NOT NULL, location VARCHAR(500) NOT NULL,
location_url VARCHAR(500), location_url VARCHAR(500),
price DECIMAL(10, 2) NOT NULL DEFAULT 0, price DECIMAL(10, 2) NOT NULL DEFAULT 0,
@@ -610,32 +565,6 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`); await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migrate event datetime columns from TIMESTAMP to TIMESTAMPTZ for
// unambiguous UTC storage (eliminates pg driver timezone interpretation).
try {
await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN start_datetime TYPE TIMESTAMPTZ USING start_datetime AT TIME ZONE 'UTC'`);
} catch (e) { /* already timestamptz or other issue */ }
try {
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` 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,
@@ -670,11 +599,6 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`); await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migration: Add is_guest column to tickets
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS payments ( CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -724,10 +648,6 @@ 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,
@@ -752,23 +672,12 @@ 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,
@@ -788,13 +697,6 @@ 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,
@@ -974,43 +876,6 @@ async function migrate() {
`); `);
} }
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
(db as any).select().from(events).orderBy((events as any).createdAt)
);
const assignedSlugs: string[] = allEvents
.filter((e) => e.slug)
.map((e) => e.slug as string);
let backfilled = 0;
for (const ev of allEvents) {
if (ev.slug) continue;
const slug = uniqueSlug(ev.title || 'event', assignedSlugs);
assignedSlugs.push(slug);
await (db as any).update(events).set({ slug }).where(eq((events as any).id, ev.id));
backfilled++;
}
if (backfilled > 0) {
console.log(`Backfilled slugs for ${backfilled} event(s).`);
// Bust the frontend cache so the homepage / sitemap pick up the new slugs
// immediately instead of serving stale (pre-slug) data for up to the
// revalidate window. Awaited (not fire-and-forget) so it runs before exit.
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const secret = process.env.REVALIDATE_SECRET;
if (secret) {
try {
const res = await fetch(`${frontendUrl}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
});
console.log(res.ok ? 'Frontend cache revalidated.' : `Frontend revalidation returned ${res.status}.`);
} catch (e: any) {
console.warn('Frontend revalidation skipped (frontend not reachable):', e?.message || e);
}
}
}
console.log('Migrations completed successfully!'); console.log('Migrations completed successfully!');
process.exit(0); process.exit(0);
} }

View File

@@ -62,7 +62,6 @@ 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(),
@@ -84,13 +83,6 @@ 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
@@ -107,7 +99,6 @@ export const sqliteTickets = sqliteTable('tickets', {
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
qrCode: text('qr_code'), qrCode: text('qr_code'),
adminNote: text('admin_note'), adminNote: text('admin_note'),
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
}); });
@@ -135,10 +126,6 @@ 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
@@ -170,10 +157,6 @@ 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' }),
@@ -403,15 +386,14 @@ 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(),
descriptionEs: pgText('description_es'), descriptionEs: pgText('description_es'),
shortDescription: varchar('short_description', { length: 300 }), shortDescription: varchar('short_description', { length: 300 }),
shortDescriptionEs: varchar('short_description_es', { length: 300 }), shortDescriptionEs: varchar('short_description_es', { length: 300 }),
startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(), startDatetime: timestamp('start_datetime').notNull(),
endDatetime: timestamp('end_datetime', { withTimezone: true }), endDatetime: timestamp('end_datetime'),
location: varchar('location', { length: 500 }).notNull(), location: varchar('location', { length: 500 }).notNull(),
locationUrl: varchar('location_url', { length: 500 }), locationUrl: varchar('location_url', { length: 500 }),
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'), price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
@@ -425,13 +407,6 @@ 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
@@ -448,7 +423,6 @@ export const pgTickets = pgTable('tickets', {
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
qrCode: varchar('qr_code', { length: 255 }), qrCode: varchar('qr_code', { length: 255 }),
adminNote: pgText('admin_note'), adminNote: pgText('admin_note'),
isGuest: pgInteger('is_guest').notNull().default(0),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
}); });
@@ -475,10 +449,6 @@ 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),
@@ -504,10 +474,6 @@ 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'),
@@ -681,7 +647,6 @@ export const pgSiteSettings = pgTable('site_settings', {
// Export the appropriate schema based on DB_TYPE // Export the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers; export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents; export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets; export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments; export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts; export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;

View File

@@ -748,10 +748,6 @@ 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,
@@ -770,10 +766,6 @@ 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,
@@ -893,9 +885,7 @@ export const emailService = {
// Add payment-method specific variables // Add payment-method specific variables
if (payment.provider === 'tpago') { if (payment.provider === 'tpago') {
// Select the TPago link matching the number of tickets (1-5), falling back to the base link variables.tpagoLink = paymentConfig.tpagoLink || '';
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 || '';

View File

@@ -1,27 +0,0 @@
/**
* 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

@@ -41,49 +41,6 @@ export function toDbDate(date: Date | string): string | Date {
return getDbType() === 'postgres' ? d : d.toISOString(); return getDbType() === 'postgres' ? d : d.toISOString();
} }
const NAIVE_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
/**
* Parse a datetime string that represents wall-clock time in a given timezone
* and return the corresponding UTC Date.
*
* Naive strings (no "Z" or offset) are interpreted as wall-clock time in
* `timezone`. Strings that already carry a timezone indicator are parsed
* directly so existing UTC ISO values still work.
*/
export function parseEventDatetime(
datetime: string,
timezone: string = 'America/Asuncion',
): Date {
if (!NAIVE_DATETIME_RE.test(datetime)) {
return new Date(datetime);
}
// Treat the digits as UTC so we have a stable reference instant.
const fakeUTC = new Date(datetime + 'Z');
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
// The gap between the two tells us the tz offset at this point in time.
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
return new Date(fakeUTC.getTime() + offsetMs);
}
/**
* Convert a datetime string to the appropriate DB format, interpreting naive
* strings as wall-clock time in `timezone` (defaults to America/Asuncion).
*/
export function toDbDateTz(
datetime: string,
timezone: string = 'America/Asuncion',
): string | Date {
const d = parseEventDatetime(datetime, timezone);
return getDbType() === 'postgres' ? d : d.toISOString();
}
/** /**
* Convert a boolean value to the appropriate format for the database type. * Convert a boolean value to the appropriate format for the database type.
* - SQLite: returns boolean (true/false) for mode: 'boolean' * - SQLite: returns boolean (true/false) for mode: 'boolean'

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm'; import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
@@ -129,8 +129,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed'), eq((tickets as any).status, 'confirmed')
ne((tickets as any).isGuest, 1)
) )
) )
); );
@@ -142,8 +141,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'checked_in'), eq((tickets as any).status, 'checked_in')
ne((tickets as any).isGuest, 1)
) )
) )
); );

View File

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

View File

@@ -1,11 +1,10 @@
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, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } 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, 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 {
@@ -32,55 +31,6 @@ 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) {
@@ -113,7 +63,6 @@ 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(),
@@ -215,10 +164,13 @@ eventsRouter.get('/', async (c) => {
return c.json({ events: eventsWithCounts }); return c.json({ events: eventsWithCounts });
}); });
// Get single event (public) - resolves by id, canonical slug, or historical alias // Get single event (public)
eventsRouter.get('/:id', async (c) => { eventsRouter.get('/:id', async (c) => {
const param = c.req.param('id'); const id = 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);
@@ -232,7 +184,7 @@ eventsRouter.get('/:id', async (c) => {
.from(tickets) .from(tickets)
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), eq((tickets as any).eventId, id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
) )
) )
@@ -249,13 +201,6 @@ eventsRouter.get('/:id', async (c) => {
}); });
}); });
async function getSiteTimezone(): Promise<string> {
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
return settings?.timezone || 'America/Asuncion';
}
// Helper function to get ticket count for an event // Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> { async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>( const ticketCount = await dbGet<any>(
@@ -371,21 +316,15 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
const user = c.get('user'); const user = c.get('user');
const now = getNow(); const now = getNow();
const id = generateId(); const id = generateId();
const tz = await getSiteTimezone();
// 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: toDbDate(data.startDatetime),
startDatetime: toDbDateTz(data.startDatetime, tz), endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null,
endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -404,7 +343,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<any>( const existing = await dbGet(
(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) {
@@ -412,51 +351,14 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
} }
const now = getNow(); const now = getNow();
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 = toDbDate(data.startDatetime);
} }
if (data.endDatetime !== undefined) { if (data.endDatetime !== undefined) {
updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null; updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : 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)
@@ -518,9 +420,6 @@ 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)
@@ -563,15 +462,11 @@ 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,
slug, title: `${existing.title} (Copy)`,
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,
@@ -597,44 +492,4 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201); return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
}); });
// List slug aliases for an event (admin/organizer only)
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
const aliases = await dbAll<any>(
(db as any)
.select({ slug: (eventSlugAliases as any).slug, createdAt: (eventSlugAliases as any).createdAt })
.from(eventSlugAliases)
.where(eq((eventSlugAliases as any).eventId, id))
);
return c.json({ aliases });
});
// Remove a slug alias from an event (admin/organizer only)
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const slug = c.req.param('slug');
const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
await (db as any)
.delete(eventSlugAliases)
.where(and(eq((eventSlugAliases as any).eventId, id), eq((eventSlugAliases as any).slug, slug)));
return c.json({ message: 'Alias removed' });
});
export default eventsRouter; export default eventsRouter;

View File

@@ -18,10 +18,6 @@ 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(),
@@ -44,10 +40,6 @@ 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(),
@@ -76,10 +68,6 @@ 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,
@@ -183,10 +171,6 @@ 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,
@@ -209,10 +193,6 @@ 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,

View File

@@ -631,21 +631,11 @@ 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,
}, },
}); });
}); });
@@ -1404,142 +1394,6 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
}, 201); }, 201);
}); });
// Admin invite guest ticket (free, confirmed, not counted in revenue)
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
firstName: z.string().min(1),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
adminNote: z.string().max(1000).optional(),
})), async (c) => {
const data = c.req.valid('json');
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
const now = getNow();
const adminUser = (c as any).get('user');
// Find or create user (use placeholder email if none provided)
const attendeeEmail = data.email && data.email.trim()
? data.email.trim()
: `guest-${generateId()}@guestinvite.local`;
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
password: '',
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for existing active ticket (only for real emails, not placeholder)
if (data.email && data.email.trim()) {
const existingTicket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
);
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'This person already has a ticket for this event' }, 400);
}
}
const ticketId = generateId();
const qrCode = generateTicketCode();
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: 'confirmed',
isGuest: 1,
qrCode,
checkinAt: null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create a $0 payment record to track the invite
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: 0,
currency: event.currency,
status: 'paid',
reference: 'Guest invite',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// Send booking confirmation email if a real email was provided
if (data.email && data.email.trim()) {
emailService.sendBookingConfirmation(ticketId).then(result => {
if (result.success) {
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
});
}
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
message: 'Guest ticket created successfully',
}, 201);
});
// Get all tickets (admin) - includes payment for each ticket // Get all tickets (admin) - includes payment for each ticket
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -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, getTpagoLink } from '@/lib/utils'; import { formatPrice, formatDateLong, formatTime } 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';
@@ -110,10 +110,6 @@ export default function BookingPage() {
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
// Terms & Privacy agreement (not persisted across page loads)
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [termsError, setTermsError] = useState<string | null>(null);
const rucPattern = /^\d{6,10}$/; const rucPattern = /^\d{6,10}$/;
// Format RUC input: digits only, max 10 // Format RUC input: digits only, max 10
@@ -166,7 +162,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.slug}`); router.push(`/events/${eventRes.event.id}`);
return; return;
} }
@@ -221,19 +217,6 @@ export default function BookingPage() {
} }
}, [user]); }, [user]);
// Clear the terms error as soon as the user agrees
useEffect(() => {
if (agreedToTerms && termsError) {
setTermsError(null);
}
}, [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');
@@ -278,20 +261,7 @@ export default function BookingPage() {
setErrors(newErrors); setErrors(newErrors);
setAttendeeErrors(newAttendeeErrors); setAttendeeErrors(newAttendeeErrors);
return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0;
let termsOk = true;
if (!agreedToTerms) {
setTermsError(t('booking.form.errors.termsRequired'));
termsOk = false;
} else {
setTermsError(null);
}
return (
Object.keys(newErrors).length === 0 &&
Object.keys(newAttendeeErrors).length === 0 &&
termsOk
);
}; };
// Connect to SSE for real-time payment updates // Connect to SSE for real-time payment updates
@@ -406,10 +376,6 @@ export default function BookingPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!agreedToTerms) {
setTermsError(t('booking.form.errors.termsRequired'));
return;
}
if (!event || !validateForm()) return; if (!event || !validateForm()) return;
setSubmitting(true); setSubmitting(true);
@@ -665,7 +631,6 @@ 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">
@@ -756,9 +721,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>
{tpagoLink && ( {paymentConfig.tpagoLink && (
<a <a
href={tpagoLink} href={paymentConfig.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"
@@ -1050,7 +1015,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.slug}`} href={`/events/${event.id}`}
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" />
@@ -1358,58 +1323,13 @@ export default function BookingPage() {
</div> </div>
</Card> </Card>
{/* Terms & Privacy agreement */}
<Card className="mb-6 p-6">
<div className="flex items-start gap-3">
<input
id="booking-terms-agree"
type="checkbox"
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
aria-required="true"
aria-invalid={termsError ? true : undefined}
aria-describedby={termsError ? 'booking-terms-error' : undefined}
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
/>
<label
htmlFor="booking-terms-agree"
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
>
{t('booking.form.termsAgreePart1')}
<Link
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.termsOfService')}
</Link>
{t('booking.form.termsAgreePart2')}
<Link
href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.privacyPolicy')}
</Link>
{t('booking.form.termsAgreePart3')}
</label>
</div>
{termsError && (
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
{termsError}
</p>
)}
</Card>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
className="w-full" className="w-full"
isLoading={submitting} isLoading={submitting}
disabled={paymentMethods.length === 0 || !agreedToTerms} disabled={paymentMethods.length === 0}
> >
{formData.paymentMethod === 'cash' {formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot') ? t('booking.form.reserveSpot')
@@ -1418,6 +1338,10 @@ export default function BookingPage() {
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment' : locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
} }
</Button> </Button>
<p className="text-center text-sm text-gray-500 mt-4">
{t('booking.form.termsNote')}
</p>
</form> </form>
)} )}
</div> </div>

View File

@@ -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, getTpagoLink } from '@/lib/utils'; import { formatPrice, formatDateLong, formatTime } 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,7 +305,6 @@ 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">
@@ -419,9 +418,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>
{tpagoLink && ( {paymentConfig.tpagoLink && (
<a <a
href={tpagoLink} href={paymentConfig.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"

View File

@@ -69,7 +69,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
} }
return ( return (
<Link href={`/events/${nextEvent.slug}`} className="block group"> <Link href={`/events/${nextEvent.id}`} 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 */}
@@ -102,11 +102,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
<div className="mt-4 md:mt-5 space-y-2"> <div className="mt-4 md:mt-5 space-y-2">
<div className="flex items-center gap-2.5 text-gray-700 text-sm"> <div className="flex items-center gap-2.5 text-gray-700 text-sm">
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" /> <CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span suppressHydrationWarning>{formatDate(nextEvent.startDatetime)}</span> <span>{formatDate(nextEvent.startDatetime)}</span>
</div> </div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm"> <div className="flex items-center gap-2.5 text-gray-700 text-sm">
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" /> <ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span suppressHydrationWarning>{fmtTime(nextEvent.startDatetime)}</span> <span>{fmtTime(nextEvent.startDatetime)}</span>
</div> </div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm"> <div className="flex items-center gap-2.5 text-gray-700 text-sm">
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" /> <MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />

View File

@@ -4,7 +4,6 @@ import { useState } from 'react';
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 { UserPayment } from '@/lib/api'; import { UserPayment } from '@/lib/api';
import { parseDate } from '@/lib/utils';
interface PaymentsTabProps { interface PaymentsTabProps {
payments: UserPayment[]; payments: UserPayment[];
@@ -22,7 +21,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
}); });
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -7,7 +7,6 @@ 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';
import { dashboardApi, UserProfile } from '@/lib/api'; import { dashboardApi, UserProfile } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
interface ProfileTabProps { interface ProfileTabProps {
@@ -117,7 +116,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
</span> </span>
<span className="font-medium"> <span className="font-medium">
{profile?.memberSince {profile?.memberSince
? parseDate(profile.memberSince).toLocaleDateString( ? new Date(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US', language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' } { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
) )

View File

@@ -7,7 +7,6 @@ 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';
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api'; import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function SecurityTab() { export default function SecurityTab() {
@@ -148,7 +147,7 @@ export default function SecurityTab() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -5,7 +5,6 @@ import Link from 'next/link';
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 { UserTicket } from '@/lib/api'; import { UserTicket } from '@/lib/api';
import { parseDate } from '@/lib/utils';
interface TicketsTabProps { interface TicketsTabProps {
tickets: UserTicket[]; tickets: UserTicket[];
@@ -27,7 +26,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
}); });
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

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

View File

@@ -1,5 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { notFound, permanentRedirect } from 'next/navigation'; import { notFound } 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,7 +7,6 @@ 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;
@@ -69,7 +68,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
title, title,
description, description,
type: 'website', type: 'website',
url: `${siteUrl}/events/${event.slug}`, url: `${siteUrl}/events/${event.id}`,
images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }], images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }],
}, },
twitter: { twitter: {
@@ -79,7 +78,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
images: [imageUrl], images: [imageUrl],
}, },
alternates: { alternates: {
canonical: `${siteUrl}/events/${event.slug}`, canonical: `${siteUrl}/events/${event.id}`,
}, },
}; };
} }
@@ -120,11 +119,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.slug}`, url: `${siteUrl}/events/${event.id}`,
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.slug}`, url: `${siteUrl}/events/${event.id}`,
}; };
} }
@@ -135,11 +134,6 @@ 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 (
@@ -148,7 +142,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={event.slug} initialEvent={event} /> <EventDetailClient eventId={params.id} initialEvent={event} />
</> </>
); );
} }

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import HeroSection from './components/HeroSection';
import NextEventSectionWrapper from './components/NextEventSectionWrapper'; import NextEventSectionWrapper from './components/NextEventSectionWrapper';
import AboutSection from './components/AboutSection'; import AboutSection from './components/AboutSection';
import MediaCarouselSection from './components/MediaCarouselSection'; import MediaCarouselSection from './components/MediaCarouselSection';
import { parseDate } from '@/lib/utils';
import NewsletterSection from './components/NewsletterSection'; import NewsletterSection from './components/NewsletterSection';
import HomepageFaqSection from './components/HomepageFaqSection'; import HomepageFaqSection from './components/HomepageFaqSection';
import { getCarouselImages } from '@/lib/carouselImages'; import { getCarouselImages } from '@/lib/carouselImages';
@@ -13,7 +12,6 @@ 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;
@@ -65,7 +63,7 @@ export async function generateMetadata(): Promise<Metadata> {
}; };
} }
const eventDate = parseDate(event.startDatetime).toLocaleDateString('en-US', { const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -140,10 +138,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.slug}`, url: `${siteUrl}/events/${event.id}`,
}, },
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.slug}`, url: `${siteUrl}/events/${event.id}`,
}; };
} }

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import { parseDate } 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 { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
@@ -117,7 +116,7 @@ export default function AdminBookingsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

@@ -7,7 +7,6 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline'; import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { parseDate } from '@/lib/utils';
export default function AdminContactsPage() { export default function AdminContactsPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
@@ -45,7 +44,7 @@ export default function AdminContactsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api'; import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
import { parseDate } 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';
@@ -22,7 +21,6 @@ import {
ChevronRightIcon, ChevronRightIcon,
XMarkIcon, XMarkIcon,
ArrowPathIcon, ArrowPathIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -56,9 +54,6 @@ export default function AdminEmailsPage() {
const [logsOffset, setLogsOffset] = useState(0); const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0); const [logsTotal, setLogsTotal] = useState(0);
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all'); const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
const [logsSearch, setLogsSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [logsEventFilter, setLogsEventFilter] = useState('');
const [resendingLogId, setResendingLogId] = useState<string | null>(null); const [resendingLogId, setResendingLogId] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null); const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
@@ -218,20 +213,11 @@ export default function AdminEmailsPage() {
} }
}; };
useEffect(() => {
const handle = setTimeout(() => setDebouncedSearch(logsSearch), 300);
return () => clearTimeout(handle);
}, [logsSearch]);
useEffect(() => {
setLogsOffset(0);
}, [debouncedSearch, logsEventFilter]);
useEffect(() => { useEffect(() => {
if (activeTab === 'logs') { if (activeTab === 'logs') {
loadLogs(); loadLogs();
} }
}, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]); }, [activeTab, logsOffset, logsSubTab]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -254,8 +240,6 @@ export default function AdminEmailsPage() {
limit: 20, limit: 20,
offset: logsOffset, offset: logsOffset,
...(logsSubTab === 'failed' ? { status: 'failed' } : {}), ...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(logsEventFilter ? { eventId: logsEventFilter } : {}),
}); });
setLogs(res.logs); setLogs(res.logs);
setLogsTotal(res.pagination.total); setLogsTotal(res.pagination.total);
@@ -407,7 +391,7 @@ export default function AdminEmailsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -588,7 +572,7 @@ export default function AdminEmailsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasDraft && ( {hasDraft && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
Draft saved {composeForm.savedAt ? parseDate(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''} Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
</span> </span>
)} )}
<Button variant="outline" size="sm" onClick={saveDraft}> <Button variant="outline" size="sm" onClick={saveDraft}>
@@ -614,7 +598,7 @@ export default function AdminEmailsPage() {
<option value="">Choose an event</option> <option value="">Choose an event</option>
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => ( {events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
<option key={event.id} value={event.id}> <option key={event.id} value={event.id}>
{event.title} - {parseDate(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} {event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</option> </option>
))} ))}
</select> </select>
@@ -772,41 +756,6 @@ export default function AdminEmailsPage() {
</nav> </nav>
</div> </div>
{/* Filters: search + event */}
<div className="flex flex-col md:flex-row gap-3 mb-4">
<div className="relative flex-1">
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={logsSearch}
onChange={(e) => setLogsSearch(e.target.value)}
placeholder="Search by recipient or subject..."
className="w-full pl-10 pr-10 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
{logsSearch && (
<button
onClick={() => setLogsSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-btn"
title="Clear search"
>
<XMarkIcon className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
<select
value={logsEventFilter}
onChange={(e) => setLogsEventFilter(e.target.value)}
className="w-full md:w-64 px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="">All events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title}
</option>
))}
</select>
</div>
{/* Desktop: Table */} {/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block"> <Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -822,7 +771,7 @@ export default function AdminEmailsPage() {
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{logs.length === 0 ? ( {logs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr> <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={log.id} className="hover:bg-gray-50">
@@ -879,7 +828,7 @@ export default function AdminEmailsPage() {
{/* Mobile: Card List */} {/* Mobile: Card List */}
<div className="md:hidden space-y-2"> <div className="md:hidden space-y-2">
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div> <div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}> <Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>

View File

@@ -5,7 +5,6 @@ import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api'; import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
import { formatDateLong, formatDateCompact, formatTime, parseDate, EVENT_TIMEZONE } 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 { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
@@ -39,7 +38,6 @@ import {
ArrowDownTrayIcon, ArrowDownTrayIcon,
ChevronDownIcon, ChevronDownIcon,
EllipsisVerticalIcon, EllipsisVerticalIcon,
StarIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -90,14 +88,6 @@ export default function AdminEventDetailPage() {
phone: '', phone: '',
adminNote: '', adminNote: '',
}); });
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
const [inviteGuestForm, setInviteGuestForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet) // Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
@@ -222,9 +212,32 @@ export default function AdminEventDetailPage() {
} }
}; };
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const formatDate = (dateStr: string) => {
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es'); return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};
const formatDateShort = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'America/Asuncion',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};
const formatCurrency = (amount: number, currency: string) => { const formatCurrency = (amount: number, currency: string) => {
if (currency === 'PYG') { if (currency === 'PYG') {
@@ -363,30 +376,6 @@ export default function AdminEventDetailPage() {
} }
}; };
const handleInviteGuest = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.guestCreate({
eventId: event.id,
firstName: inviteGuestForm.firstName,
lastName: inviteGuestForm.lastName || undefined,
email: inviteGuestForm.email || undefined,
phone: inviteGuestForm.phone || undefined,
adminNote: inviteGuestForm.adminNote || undefined,
});
toast.success('Guest invited successfully');
setShowInviteGuestModal(false);
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to invite guest');
} finally {
setSubmitting(false);
}
};
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => { const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
if (!event) return; if (!event) return;
setExporting(true); setExporting(true);
@@ -477,7 +466,7 @@ export default function AdminEventDetailPage() {
ticketId: 'TKT-PREVIEW', ticketId: 'TKT-PREVIEW',
eventTitle: event?.title || '', eventTitle: event?.title || '',
eventDate: event ? formatDate(event.startDatetime) : '', eventDate: event ? formatDate(event.startDatetime) : '',
eventTime: event ? fmtTime(event.startDatetime) : '', eventTime: event ? formatTime(event.startDatetime) : '',
eventLocation: event?.location || '', eventLocation: event?.location || '',
eventLocationUrl: event?.locationUrl || '', eventLocationUrl: event?.locationUrl || '',
eventPrice: event ? formatCurrency(event.price, event.currency) : '', eventPrice: event ? formatCurrency(event.price, event.currency) : '',
@@ -549,9 +538,7 @@ export default function AdminEventDetailPage() {
const pendingCount = getTicketsByStatus('pending').length; const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length; const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length; const cancelledCount = getTicketsByStatus('cancelled').length;
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length; const revenue = (confirmedCount + checkedInCount) * event.price;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [ const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon }, { key: 'overview', label: 'Overview', icon: CalendarIcon },
@@ -586,7 +573,7 @@ export default function AdminEventDetailPage() {
</Link> </Link>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {fmtTime(event.startDatetime)}</p> <p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {formatTime(event.startDatetime)}</p>
</div> </div>
{/* Desktop header actions */} {/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0"> <div className="hidden md:flex items-center gap-2 flex-shrink-0">
@@ -594,7 +581,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.slug}`} target="_blank"> <Link href={`/events/${event.id}`} 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 +605,7 @@ export default function AdminEventDetailPage() {
</button> </button>
} }
> >
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}> <DropdownItem onClick={() => { window.open(`/events/${event.id}`, '_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); }}>
@@ -636,7 +623,7 @@ export default function AdminEventDetailPage() {
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]"> <div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CalendarIcon className="w-3.5 h-3.5" /> <CalendarIcon className="w-3.5 h-3.5" />
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` ${fmtTime(event.endDatetime)}`} {formatDateShort(event.startDatetime)} {formatTime(event.startDatetime)}{event.endDatetime && ` ${formatTime(event.endDatetime)}`}
</span> </span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<MapPinIcon className="w-3.5 h-3.5" /> <MapPinIcon className="w-3.5 h-3.5" />
@@ -791,7 +778,7 @@ export default function AdminEventDetailPage() {
<div> <div>
<p className="font-medium text-sm">Date & Time</p> <p className="font-medium text-sm">Date & Time</p>
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p> <p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
<p className="text-sm text-gray-600">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}</p> <p className="text-sm text-gray-600">{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -922,9 +909,6 @@ export default function AdminEventDetailPage() {
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}> <DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door <PlusIcon className="w-4 h-4 mr-2" /> Add at Door
</DropdownItem> </DropdownItem>
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
</DropdownItem>
</Dropdown> </Dropdown>
</div> </div>
{(searchQuery || statusFilter !== 'all') && ( {(searchQuery || statusFilter !== 'all') && (
@@ -1022,20 +1006,15 @@ export default function AdminEventDetailPage() {
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>} {ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<div className="flex items-center gap-1 flex-wrap"> {getStatusBadge(ticket.status, true)}
{getStatusBadge(ticket.status, true)}
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
</div>
{ticket.checkinAt && ( {ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5"> <p className="text-[10px] text-gray-400 mt-0.5">
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })} {new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}
</p> </p>
)} )}
</td> </td>
<td className="px-4 py-2.5 text-xs text-gray-500"> <td className="px-4 py-2.5 text-xs text-gray-500">
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })} {new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
@@ -1087,17 +1066,14 @@ export default function AdminEventDetailPage() {
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p> <p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>} {ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end"> <div className="flex items-center gap-1.5 flex-shrink-0">
{getStatusBadge(ticket.status, true)} {getStatusBadge(ticket.status, true)}
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
</div> </div>
</div> </div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100"> <div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400"> <p className="text-[10px] text-gray-400">
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })} {new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`} {ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{primary && ( {primary && (
@@ -1254,8 +1230,8 @@ export default function AdminEventDetailPage() {
</td> </td>
<td className="px-4 py-2.5 text-xs text-gray-500"> <td className="px-4 py-2.5 text-xs text-gray-500">
{ticket.checkinAt ? ( {ticket.checkinAt ? (
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
}) })
) : ( ) : (
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
@@ -1317,7 +1293,7 @@ export default function AdminEventDetailPage() {
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100"> <div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400"> <p className="text-[10px] text-gray-400">
{ticket.checkinAt {ticket.checkinAt
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}` ? `Checked in ${new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`
: 'Not checked in'} : 'Not checked in'}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -1519,32 +1495,15 @@ 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' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'} {locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
</label> </label>
<p className="text-[10px] text-gray-500 mb-2"> <input
{locale === 'es' type="url"
? 'Cada enlace tiene un monto fijo. Un enlace distinto por cantidad de tickets.' value={paymentOverrides.tpagoLink ?? ''}
: 'Each link has a fixed amount. One link per ticket quantity.'} onChange={(e) => updatePaymentOverride('tpagoLink', e.target.value || null)}
</p> placeholder={globalPaymentOptions?.tpagoLink || 'https://www.tpago.com.py/links?alias=...'}
<div className="space-y-2"> 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"
{([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[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>
<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>
@@ -1878,16 +1837,6 @@ export default function AdminEventDetailPage() {
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p> <p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
</div> </div>
</button> </button>
<button
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<StarIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Invite Guest</p>
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</p>
</div>
</button>
</div> </div>
</BottomSheet> </BottomSheet>
@@ -2097,86 +2046,6 @@ export default function AdminEventDetailPage() {
</div> </div>
)} )}
{/* Invite Guest Modal */}
{showInviteGuestModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowInviteGuestModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Invite Guest</h2>
<p className="text-xs text-gray-500">Free ticket not counted in revenue</p>
</div>
<button onClick={() => setShowInviteGuestModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={inviteGuestForm.firstName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={inviteGuestForm.lastName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={inviteGuestForm.email}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com (optional)" />
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={inviteGuestForm.phone}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={inviteGuestForm.adminNote}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-800">
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<StarIcon className="w-4 h-4 mr-1.5" />
Invite Guest
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Note Modal */} {/* Note Modal */}
{showNoteModal && selectedTicket && ( {showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">

View File

@@ -14,7 +14,6 @@ import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateI
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
export default function AdminEventsPage() { export default function AdminEventsPage() {
const router = useRouter(); const router = useRouter();
@@ -28,11 +27,9 @@ 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;
@@ -51,7 +48,6 @@ export default function AdminEventsPage() {
}>({ }>({
title: '', title: '',
titleEs: '', titleEs: '',
slug: '',
description: '', description: '',
descriptionEs: '', descriptionEs: '',
shortDescription: '', shortDescription: '',
@@ -116,9 +112,8 @@ export default function AdminEventsPage() {
}; };
const resetForm = () => { const resetForm = () => {
setSlugAliases([]);
setFormData({ setFormData({
title: '', titleEs: '', slug: '', description: '', descriptionEs: '', title: '', titleEs: '', 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,
@@ -128,45 +123,18 @@ export default function AdminEventsPage() {
}; };
const isoToLocalDatetime = (isoString: string): string => { const isoToLocalDatetime = (isoString: string): string => {
const date = parseDate(isoString); const date = new Date(isoString);
const parts = new Intl.DateTimeFormat('en-US', { const year = date.getFullYear();
timeZone: EVENT_TIMEZONE, const month = String(date.getMonth() + 1).padStart(2, '0');
year: 'numeric', const day = String(date.getDate()).padStart(2, '0');
month: '2-digit', const hours = String(date.getHours()).padStart(2, '0');
day: '2-digit', const minutes = String(date.getMinutes()).padStart(2, '0');
hour: '2-digit', return `${year}-${month}-${day}T${hours}:${minutes}`;
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: string) => parts.find(p => p.type === type)!.value;
const h = get('hour') === '24' ? '00' : get('hour');
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 || '', slug: event.slug || '', title: event.title, titleEs: event.titleEs || '',
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),
@@ -179,7 +147,6 @@ 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) => {
@@ -196,12 +163,12 @@ export default function AdminEventsPage() {
setSaving(false); setSaving(false);
return; return;
} }
const eventData: Partial<Event> = { const eventData = {
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,
startDatetime: formData.startDatetime, startDatetime: new Date(formData.startDatetime).toISOString(),
endDatetime: formData.endDatetime || undefined, endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined, location: formData.location, locationUrl: formData.locationUrl || undefined,
price: formData.price, currency: formData.currency, capacity: formData.capacity, price: formData.price, currency: formData.currency, capacity: formData.capacity,
status: formData.status, bannerUrl: formData.bannerUrl || undefined, status: formData.status, bannerUrl: formData.bannerUrl || undefined,
@@ -209,8 +176,6 @@ 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 {
@@ -249,7 +214,7 @@ export default function AdminEventsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
}); });
}; };
@@ -327,38 +292,6 @@ export default function AdminEventsPage() {
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} /> onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
</div> </div>
{editingEvent && (
<div>
<Input label="URL Slug" value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="auto-generated from title" />
<p className="text-xs text-gray-500 mt-1">
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
. Changing the slug keeps the old one as a redirecting alias.
</p>
{slugAliases.length > 0 && (
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
<p className="text-sm font-medium mb-2">URL aliases</p>
<p className="text-xs text-gray-500 mb-2">
Old URLs that still redirect to the current slug. Removing one breaks those links.
</p>
<ul className="space-y-1">
{slugAliases.map((alias) => (
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
<span className="font-mono truncate">/events/{alias.slug}</span>
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
title="Remove alias">
<TrashIcon className="w-4 h-4" />
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1">Description (English)</label> <label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea value={formData.description} <textarea value={formData.description}

View File

@@ -3,7 +3,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { mediaApi, Media } from '@/lib/api'; import { mediaApi, Media } from '@/lib/api';
import { parseDate } 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 {
@@ -109,7 +108,7 @@ export default function AdminGalleryPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi, LegalPage } from '@/lib/api'; import { legalPagesApi, LegalPage } from '@/lib/api';
import { parseDate } 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';
@@ -159,7 +158,7 @@ export default function AdminLegalPagesPage() {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
try { try {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -15,7 +15,6 @@ import {
UserGroupIcon, UserGroupIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { parseDate } from '@/lib/utils';
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
@@ -31,7 +30,7 @@ export default function AdminDashboardPage() {
}, []); }, []);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',

View File

@@ -26,10 +26,6 @@ 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,
@@ -144,31 +140,13 @@ 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' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'} {locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
</label> </label>
<p className="text-xs text-gray-500 mb-2"> <Input
{locale === 'es' value={options.tpagoLink || ''}
? 'Cada enlace tiene un monto fijo. Usá un enlace distinto para cada cantidad de tickets.' onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
: 'Each link has a fixed amount. Use a different link for each ticket quantity.'} placeholder="https://www.tpago.com.py/links?alias=..."
</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[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>
<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>

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api'; import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { parseDate } 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';
@@ -204,7 +203,7 @@ export default function AdminPaymentsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
@@ -281,22 +280,6 @@ export default function AdminPaymentsPage() {
}; };
}; };
// Hide pending-approval payments whose event has already ended.
// Fall back to startDatetime when endDatetime is absent; keep visible when we
// can't classify (event missing from list and no startDatetime on payment.event).
const visiblePendingApprovalPayments = (() => {
const now = new Date();
return pendingApprovalPayments.filter((payment) => {
const eventId = payment.event?.id;
const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined;
const endIso = fullEvent?.endDatetime
|| fullEvent?.startDatetime
|| payment.event?.startDatetime;
if (!endIso) return true;
return parseDate(endIso).getTime() >= now.getTime();
});
})();
// Get booking info for pending approval payments // Get booking info for pending approval payments
const getPendingBookingInfo = (payment: PaymentWithDetails) => { const getPendingBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) { if (!payment.ticket?.bookingId) {
@@ -304,7 +287,7 @@ export default function AdminPaymentsPage() {
} }
// Count all pending payments with the same bookingId // Count all pending payments with the same bookingId
const bookingPayments = visiblePendingApprovalPayments.filter( const bookingPayments = pendingApprovalPayments.filter(
p => p.ticket?.bookingId === payment.ticket?.bookingId p => p.ticket?.bookingId === payment.ticket?.bookingId
); );
@@ -342,7 +325,7 @@ export default function AdminPaymentsPage() {
const paidBookingsCount = getUniqueBookingsCount( const paidBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'paid') payments.filter(p => p.status === 'paid')
); );
const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments); const pendingApprovalBookingsCount = getUniqueBookingsCount(pendingApprovalPayments);
if (loading) { if (loading) {
return ( return (
@@ -636,8 +619,8 @@ export default function AdminPaymentsPage() {
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
<p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p> <p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p>
{visiblePendingApprovalPayments.length !== pendingApprovalBookingsCount && ( {pendingApprovalPayments.length !== pendingApprovalBookingsCount && (
<p className="text-xs text-gray-400">({visiblePendingApprovalPayments.length} tickets)</p> <p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p>
)} )}
</div> </div>
</div> </div>
@@ -686,8 +669,8 @@ export default function AdminPaymentsPage() {
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]', className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}> activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
{locale === 'es' ? 'Pendientes' : 'Pending Approval'} {locale === 'es' ? 'Pendientes' : 'Pending Approval'}
{visiblePendingApprovalPayments.length > 0 && ( {pendingApprovalPayments.length > 0 && (
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{visiblePendingApprovalPayments.length}</span> <span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span>
)} )}
</button> </button>
<button onClick={() => setActiveTab('all')} <button onClick={() => setActiveTab('all')}
@@ -701,7 +684,7 @@ export default function AdminPaymentsPage() {
{/* Pending Approval Tab */} {/* Pending Approval Tab */}
{activeTab === 'pending_approval' && ( {activeTab === 'pending_approval' && (
<> <>
{visiblePendingApprovalPayments.length === 0 ? ( {pendingApprovalPayments.length === 0 ? (
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" /> <CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
<p className="text-gray-500"> <p className="text-gray-500">
@@ -712,7 +695,7 @@ export default function AdminPaymentsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{visiblePendingApprovalPayments.map((payment) => { {pendingApprovalPayments.map((payment) => {
const bookingInfo = getPendingBookingInfo(payment); const bookingInfo = getPendingBookingInfo(payment);
return ( return (
<Card key={payment.id} className="p-4"> <Card key={payment.id} className="p-4">

View File

@@ -18,7 +18,6 @@ import {
VideoCameraIcon, VideoCameraIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import clsx from 'clsx'; import clsx from 'clsx';
// ─── Types ─────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────
@@ -325,7 +324,7 @@ function InvalidTicketScreen({
const reasonDetail: Record<InvalidReason, string> = { const reasonDetail: Record<InvalidReason, string> = {
already_checked_in: validation?.ticket?.checkinAt already_checked_in: validation?.ticket?.checkinAt
? `Checked in at ${parseDate(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}` ? `Checked in at ${new Date(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
: 'This ticket was already used', : 'This ticket was already used',
cancelled: 'This ticket has been cancelled and is no longer valid.', cancelled: 'This ticket has been cancelled and is no longer valid.',
not_found: error || 'No ticket matching this code was found.', not_found: error || 'No ticket matching this code was found.',
@@ -766,7 +765,7 @@ export default function AdminScannerPage() {
setRecentCheckins((prev) => [ setRecentCheckins((prev) => [
{ {
name: result.ticket.attendeeName || 'Guest', name: result.ticket.attendeeName || 'Guest',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }), time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
ticketId: scanResult.validation!.ticket!.id, ticketId: scanResult.validation!.ticket!.id,
}, },
...prev.slice(0, 19), ...prev.slice(0, 19),
@@ -797,7 +796,7 @@ export default function AdminScannerPage() {
setRecentCheckins((prev) => [ setRecentCheckins((prev) => [
{ {
name: result.ticket.attendeeName || 'Guest', name: result.ticket.attendeeName || 'Guest',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }), time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
ticketId: searchDetailValidation!.ticket!.id, ticketId: searchDetailValidation!.ticket!.id,
}, },
...prev.slice(0, 19), ...prev.slice(0, 19),

View File

@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api'; import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
import { parseDate } 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';
@@ -320,7 +319,7 @@ export default function AdminSettingsPage() {
<div> <div>
<p className="font-medium text-amber-900">{featuredEvent.title}</p> <p className="font-medium text-amber-900">{featuredEvent.title}</p>
<p className="text-sm text-amber-700"> <p className="text-sm text-amber-700">
{parseDate(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { {new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import { parseDate } 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';
@@ -108,7 +107,7 @@ export default function AdminTicketsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
}); });
}; };

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api'; import { usersApi, User } from '@/lib/api';
import { parseDate } 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';
@@ -105,7 +104,7 @@ export default function AdminUsersPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion', year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
}); });
}; };

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { parseDate } from '@/lib/utils';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -11,7 +10,6 @@ interface LlmsFaq {
interface LlmsEvent { interface LlmsEvent {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
shortDescription?: string; shortDescription?: string;
@@ -58,7 +56,7 @@ async function getUpcomingEvents(): Promise<LlmsEvent[]> {
const EVENT_TIMEZONE = 'America/Asuncion'; const EVENT_TIMEZONE = 'America/Asuncion';
function formatEventDate(dateStr: string): string { function formatEventDate(dateStr: string): string {
return parseDate(dateStr).toLocaleDateString('en-US', { return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -68,7 +66,7 @@ function formatEventDate(dateStr: string): string {
} }
function formatEventTime(dateStr: string): string { function formatEventTime(dateStr: string): string {
return parseDate(dateStr).toLocaleTimeString('en-US', { return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
@@ -82,7 +80,7 @@ function formatPrice(price: number, currency: string): string {
} }
function formatISODate(dateStr: string): string { function formatISODate(dateStr: string): string {
return parseDate(dateStr).toLocaleDateString('en-CA', { return new Date(dateStr).toLocaleDateString('en-CA', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -91,7 +89,7 @@ function formatISODate(dateStr: string): string {
} }
function formatISOTime(dateStr: string): string { function formatISOTime(dateStr: string): string {
return parseDate(dateStr).toLocaleTimeString('en-GB', { return new Date(dateStr).toLocaleTimeString('en-GB', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
@@ -194,7 +192,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.slug}`); lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
if (nextEvent.shortDescription) { if (nextEvent.shortDescription) {
lines.push(`- Description: ${nextEvent.shortDescription}`); lines.push(`- Description: ${nextEvent.shortDescription}`);
} }
@@ -227,7 +225,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.slug}`); lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`);
lines.push(''); lines.push('');
} }
} }

View File

@@ -5,7 +5,6 @@ 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;
@@ -101,7 +100,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.slug}`, url: `${siteUrl}/events/${event.id}`,
lastModified: new Date(event.updatedAt), lastModified: new Date(event.updatedAt),
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const), changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
priority: isUpcoming ? 0.8 : 0.5, priority: isUpcoming ? 0.8 : 0.5,

View File

@@ -29,7 +29,6 @@ export default function Footer() {
width={140} width={140}
height={40} height={40}
className="h-10 w-auto" className="h-10 w-auto"
style={{ width: 'auto' }}
/> />
</Link> </Link>
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}> <p className="mt-3 max-w-md" style={{ color: '#002F44' }}>
@@ -108,7 +107,7 @@ export default function Footer() {
{legalLinks.map((link) => ( {legalLinks.map((link) => (
<Link <Link
key={link.slug} key={link.slug}
href={`/legal/${link.slug}${locale === 'es' ? '?locale=es' : ''}`} href={`/legal/${link.slug}`}
className="hover:opacity-70 transition-colors text-sm" className="hover:opacity-70 transition-colors text-sm"
style={{ color: '#002F44' }} style={{ color: '#002F44' }}
> >

View File

@@ -124,7 +124,6 @@ export default function Header() {
width={140} width={140}
height={40} height={40}
className="h-10 w-auto" className="h-10 w-auto"
style={{ width: 'auto' }}
priority priority
/> />
</Link> </Link>
@@ -220,7 +219,6 @@ export default function Header() {
width={100} width={100}
height={28} height={28}
className="h-7 w-auto" className="h-7 w-auto"
style={{ width: 'auto' }}
/> />
<button <button
className="p-2 rounded-lg hover:bg-gray-100 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 transition-colors"

View File

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

View File

@@ -117,11 +117,7 @@
"rucOptional": "Optional - for invoice", "rucOptional": "Optional - for invoice",
"reserveSpot": "Reserve My Spot", "reserveSpot": "Reserve My Spot",
"proceedPayment": "Proceed to Payment", "proceedPayment": "Proceed to Payment",
"termsAgreePart1": "I agree to the ", "termsNote": "By booking, you agree to our terms and conditions.",
"termsOfService": "Terms of Service",
"termsAgreePart2": " and ",
"privacyPolicy": "Privacy Policy",
"termsAgreePart3": ".",
"soldOutMessage": "This event is fully booked. Check back later or browse other events.", "soldOutMessage": "This event is fully booked. Check back later or browse other events.",
"errors": { "errors": {
"nameRequired": "Please enter your full name", "nameRequired": "Please enter your full name",
@@ -133,8 +129,7 @@
"phoneRequired": "Phone number is required", "phoneRequired": "Phone number is required",
"bookingFailed": "Booking failed. Please try again.", "bookingFailed": "Booking failed. Please try again.",
"rucInvalidFormat": "Invalid format. Example: 12345678-9", "rucInvalidFormat": "Invalid format. Example: 12345678-9",
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number.", "rucInvalidCheckDigit": "Invalid RUC. Please verify the number."
"termsRequired": "You must agree to the Terms of Service and Privacy Policy to continue."
} }
}, },
"summary": { "summary": {
@@ -322,11 +317,6 @@
"refund": "Refund Policy" "refund": "Refund Policy"
} }
}, },
"legalPage": {
"backToHome": "Back to Home",
"lastUpdated": "Last updated: {date}",
"backToTop": "Back to top"
},
"linktree": { "linktree": {
"tagline": "Language Exchange Community", "tagline": "Language Exchange Community",
"nextEvent": "Next Event", "nextEvent": "Next Event",

View File

@@ -117,11 +117,7 @@
"rucOptional": "Opcional - para facturación", "rucOptional": "Opcional - para facturación",
"reserveSpot": "Reservar Mi Lugar", "reserveSpot": "Reservar Mi Lugar",
"proceedPayment": "Proceder al Pago", "proceedPayment": "Proceder al Pago",
"termsAgreePart1": "Acepto los ", "termsNote": "Al reservar, aceptas nuestros términos y condiciones.",
"termsOfService": "Términos de Servicio",
"termsAgreePart2": " y la ",
"privacyPolicy": "Política de Privacidad",
"termsAgreePart3": ".",
"soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.", "soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.",
"errors": { "errors": {
"nameRequired": "Por favor ingresa tu nombre completo", "nameRequired": "Por favor ingresa tu nombre completo",
@@ -133,8 +129,7 @@
"phoneRequired": "El número de teléfono es requerido", "phoneRequired": "El número de teléfono es requerido",
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.", "bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9", "rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
"rucInvalidCheckDigit": "RUC inválido. Verifique el número.", "rucInvalidCheckDigit": "RUC inválido. Verifique el número."
"termsRequired": "Debes aceptar los Términos de Servicio y la Política de Privacidad para continuar."
} }
}, },
"summary": { "summary": {
@@ -322,11 +317,6 @@
"refund": "Política de Reembolso" "refund": "Política de Reembolso"
} }
}, },
"legalPage": {
"backToHome": "Volver al inicio",
"lastUpdated": "Última actualización: {date}",
"backToTop": "Volver arriba"
},
"linktree": { "linktree": {
"tagline": "Comunidad de Intercambio de Idiomas", "tagline": "Comunidad de Intercambio de Idiomas",
"nextEvent": "Próximo Evento", "nextEvent": "Próximo Evento",

View File

@@ -67,12 +67,6 @@ 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
@@ -185,20 +179,6 @@ export const ticketsApi = {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
guestCreate: (data: {
eventId: string;
firstName: string;
lastName?: string;
email?: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
method: 'POST',
body: JSON.stringify(data),
}),
checkPaymentStatus: (ticketId: string) => checkPaymentStatus: (ticketId: string) =>
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>( fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
@@ -502,11 +482,10 @@ export const emailsApi = {
}), }),
// Logs // Logs
getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => { getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId); if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.limit) query.set('limit', params.limit.toString()); if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString()); if (params?.offset) query.set('offset', params.offset.toString());
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`); return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
@@ -531,7 +510,6 @@ 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;
@@ -559,7 +537,6 @@ 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;
@@ -573,7 +550,6 @@ export interface Ticket {
checkedInByAdminId?: string; checkedInByAdminId?: string;
qrCode: string; qrCode: string;
adminNote?: string; adminNote?: string;
isGuest?: boolean;
createdAt: string; createdAt: string;
event?: Event; event?: Event;
payment?: Payment; payment?: Payment;
@@ -674,10 +650,6 @@ 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;

View File

@@ -4,14 +4,9 @@
// All helpers pin the timezone to America/Asuncion so the output is identical // All helpers pin the timezone to America/Asuncion so the output is identical
// on the server (often UTC) and the client (user's local TZ). This prevents // on the server (often UTC) and the client (user's local TZ). This prevents
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client). // React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
//
// IMPORTANT — parseDate() must be used instead of raw `new Date(str)` so that
// ISO-like strings without a timezone suffix (e.g. "2026-04-02T14:00:00") are
// always treated as UTC. Without this, the same string produces a different
// instant on server (Node TZ) vs client (browser / DevTools TZ).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const EVENT_TIMEZONE = 'America/Asuncion'; const EVENT_TIMEZONE = 'America/Asuncion';
type Locale = 'en' | 'es'; type Locale = 'en' | 'es';
@@ -19,29 +14,11 @@ function pickLocale(locale: Locale): string {
return locale === 'es' ? 'es-ES' : 'en-US'; return locale === 'es' ? 'es-ES' : 'en-US';
} }
// Matches ISO-like strings that have NO timezone indicator (Z, +HH:MM, etc.)
const NAIVE_ISO_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
/**
* Parse a date string into a deterministic Date object.
*
* If the string looks like an ISO datetime but lacks a timezone suffix it is
* ambiguous — `new Date()` would interpret it in the environment's local
* timezone which differs between Node (SSR) and the browser (hydration).
* We normalise by appending "Z" so parsing always targets UTC.
*/
export function parseDate(dateStr: string): Date {
if (NAIVE_ISO_RE.test(dateStr)) {
return new Date(dateStr + 'Z');
}
return new Date(dateStr);
}
/** /**
* "Sat, Feb 14" / "sáb, 14 feb" * "Sat, Feb 14" / "sáb, 14 feb"
*/ */
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string { export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -53,7 +30,7 @@ export function formatDateShort(dateStr: string, locale: Locale = 'en'): string
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026" * "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
*/ */
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string { export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -66,7 +43,7 @@ export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
* "February 14, 2026" / "14 de febrero de 2026" (no weekday) * "February 14, 2026" / "14 de febrero de 2026" (no weekday)
*/ */
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string { export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -78,7 +55,7 @@ export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string
* "Feb 14, 2026" / "14 feb 2026" * "Feb 14, 2026" / "14 feb 2026"
*/ */
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string { export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -90,7 +67,7 @@ export function formatDateCompact(dateStr: string, locale: Locale = 'en'): strin
* "04:30 PM" / "16:30" * "04:30 PM" / "16:30"
*/ */
export function formatTime(dateStr: string, locale: Locale = 'en'): string { export function formatTime(dateStr: string, locale: Locale = 'en'): string {
return parseDate(dateStr).toLocaleTimeString(pickLocale(locale), { return new Date(dateStr).toLocaleTimeString(pickLocale(locale), {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: EVENT_TIMEZONE, timeZone: EVENT_TIMEZONE,
@@ -101,7 +78,7 @@ export function formatTime(dateStr: string, locale: Locale = 'en'): string {
* "Feb 14, 2026, 04:30 PM" — compact date + time combined * "Feb 14, 2026, 04:30 PM" — compact date + time combined
*/ */
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string { export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
return parseDate(dateStr).toLocaleString(pickLocale(locale), { return new Date(dateStr).toLocaleString(pickLocale(locale), {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -115,7 +92,7 @@ export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
* "Sat, Feb 14, 04:30 PM" — short date + time combined * "Sat, Feb 14, 04:30 PM" — short date + time combined
*/ */
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string { export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
return parseDate(dateStr).toLocaleString(pickLocale(locale), { return new Date(dateStr).toLocaleString(pickLocale(locale), {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -165,31 +142,3 @@ 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;
}