21 Commits

Author SHA1 Message Date
c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis
1b2463f4bc Add human-readable event URL slugs with legacy redirect support.
Store unique slugs on events, backfill existing records, redirect old UUID and alias URLs to canonical slug pages, and expose slug editing plus alias management in the admin event modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 04:09:05 +00:00
fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis
d09c87a5a5 Fix booking flow scroll position on mobile step changes.
Scroll to the top whenever the booking step changes so users are not left at the bottom of the page after submitting the form.
2026-06-05 02:52:14 +00:00
e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis
69768077e5 Add search and event filters to admin email logs.
Let admins find logs by recipient or subject and narrow results by event on the Email Logs tab.
2026-06-04 23:35:53 +00:00
Michilis
ecd2a7d009 fix(legal): align bullet list markers with list text
Use list-outside and left padding so ReactMarkdown and TipTap list items render markers beside text instead of on separate lines.
2026-06-04 22:58:31 +00:00
Michilis
0f7573c934 Cap event page ticket quantity at 5 per booking.
Limit the quantity stepper to five tickets or remaining spots, whichever is lower.
2026-06-04 22:53:39 +00:00
Michilis
a8b72b47b1 fix(legal): show legal pages in site language with bidirectional toggle
Wire legal pages to LanguageContext, pass locale on footer/booking links,
translate layout chrome, and restore SSR content when switching back to English.
2026-06-04 22:51:32 +00:00
defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis
22e9254f42 fix(tickets): use integer 1 for isGuest in admin guest invite
Postgres is_guest column is integer; passing JS boolean true caused
"invalid input syntax for type integer" 500 on POST /api/tickets/admin/guest.

Made-with: Cursor
2026-04-27 20:41:58 +00:00
Michilis
2cabd8c92f fix(admin): avoid rendering stray 0 for non-guest tickets on isGuest
Made-with: Love
2026-04-27 18:21:10 +00:00
Michilis
622bb5171c fix(admin): hide pending-approval payments for past events
Made-with: Cursor
2026-04-27 18:14:15 +00:00
Michilis
55516ef1e7 chore: ignore .agents directory and skills-lock.json
Made-with: Cursor
2026-04-27 18:14:15 +00:00
Michilis
3dfb1689ad Booking flow: required terms and privacy checkbox with i18n
Also includes admin, dashboard, and API updates; PWA icon assets; and
assorted layout and utility changes on dev.
2026-04-27 03:21:15 +00:00
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
Michilis
f8ebc3760d Fix db:export ENOBUFS by streaming pg_dump output to file
Made-with: Cursor
2026-03-12 19:18:24 +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
Michilis
4da26e7ef1 feat(emails): add re-send for all emails, failed tab, and resend indicators
- Add resend_attempts and last_resent_at to email_logs schema and migrations
- Add POST /api/emails/logs/:id/resend and emailService.resendFromLog
- Add resendLog API and EmailLog.resendAttempts/lastResentAt
- Add All/Failed sub-tabs, resend button for all emails, re-sent indicator in logs and detail modal

Made-with: Cursor
2026-03-12 19:13:24 +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
Michilis
e09ff4ed60 Admin: stats privacy toggle, clickable event rows, fix payment method display
- Add useStatsPrivacy hook with localStorage persistence for stats visibility
- Single event page: desktop privacy button, hide capacity chip when stats hidden
- Events list: row/card click navigates to event detail; stopPropagation on actions
- Backend GET /tickets: include payment for each ticket (removes N+1)
- Bookings page: use list payment data, show — when payment method unknown

Made-with: Cursor
2026-03-10 01:10:42 +00:00
49 changed files with 1446 additions and 224 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
@@ -43,28 +43,32 @@ function exportSqlite(outputPath: string): void {
function exportPostgres(outputPath: string): void { function exportPostgres(outputPath: string): void {
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish'; const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
const result = spawnSync( const outFd = openSync(outputPath, 'w');
'pg_dump', try {
['--clean', '--if-exists', connString], const result = spawnSync(
{ 'pg_dump',
stdio: ['ignore', 'pipe', 'pipe'], ['--clean', '--if-exists', connString],
encoding: 'utf-8', {
stdio: ['ignore', outFd, 'pipe'],
encoding: 'utf-8',
}
);
if (result.error) {
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.');
console.error(result.error.message);
process.exit(1);
} }
);
if (result.error) { if (result.status !== 0) {
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.'); console.error('pg_dump failed:', result.stderr);
console.error(result.error.message); process.exit(1);
process.exit(1); }
console.log(`Exported to ${outputPath}`);
} finally {
closeSync(outFd);
} }
if (result.status !== 0) {
console.error('pg_dump failed:', result.stderr);
process.exit(1);
}
writeFileSync(outputPath, result.stdout);
console.log(`Exported to ${outputPath}`);
} }
async function main() { async function main() {

View File

@@ -1,6 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import { db } from './index.js'; import { db, dbAll, events } from './index.js';
import { sql } from 'drizzle-orm'; import { sql, eq } from 'drizzle-orm';
import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite'; const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`); console.log(`Database type: ${dbType}`);
@@ -111,6 +112,23 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`); await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Add slug column to events (backfilled below)
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN slug TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`);
} catch (e) { /* index may already exist */ }
// Historical slugs that still resolve (and redirect) to an event's canonical slug
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS event_slug_aliases (
slug TEXT PRIMARY KEY,
event_id TEXT NOT NULL REFERENCES events(id),
created_at TEXT NOT NULL
)
`);
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -173,6 +191,11 @@ 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
@@ -368,6 +391,13 @@ async function migrate() {
) )
`); `);
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`);
} catch (e) { /* column may already exist */ }
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -526,8 +556,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 TIMESTAMP NOT NULL, start_datetime TIMESTAMPTZ NOT NULL,
end_datetime TIMESTAMP, end_datetime TIMESTAMPTZ,
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,
@@ -558,6 +588,32 @@ 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,
@@ -592,6 +648,11 @@ 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,
@@ -772,6 +833,13 @@ async function migrate() {
) )
`); `);
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -862,6 +930,43 @@ async function migrate() {
`); `);
} }
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
(db as any).select().from(events).orderBy((events as any).createdAt)
);
const assignedSlugs: string[] = allEvents
.filter((e) => e.slug)
.map((e) => e.slug as string);
let backfilled = 0;
for (const ev of allEvents) {
if (ev.slug) continue;
const slug = uniqueSlug(ev.title || 'event', assignedSlugs);
assignedSlugs.push(slug);
await (db as any).update(events).set({ slug }).where(eq((events as any).id, ev.id));
backfilled++;
}
if (backfilled > 0) {
console.log(`Backfilled slugs for ${backfilled} event(s).`);
// Bust the frontend cache so the homepage / sitemap pick up the new slugs
// immediately instead of serving stale (pre-slug) data for up to the
// revalidate window. Awaited (not fire-and-forget) so it runs before exit.
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const secret = process.env.REVALIDATE_SECRET;
if (secret) {
try {
const res = await fetch(`${frontendUrl}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
});
console.log(res.ok ? 'Frontend cache revalidated.' : `Frontend revalidation returned ${res.status}.`);
} catch (e: any) {
console.warn('Frontend revalidation skipped (frontend not reachable):', e?.message || e);
}
}
}
console.log('Migrations completed successfully!'); console.log('Migrations completed successfully!');
process.exit(0); process.exit(0);
} }

View File

@@ -62,6 +62,7 @@ export const sqliteInvoices = sqliteTable('invoices', {
export const sqliteEvents = sqliteTable('events', { export const sqliteEvents = sqliteTable('events', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
slug: text('slug').unique(),
title: text('title').notNull(), title: text('title').notNull(),
titleEs: text('title_es'), titleEs: text('title_es'),
description: text('description').notNull(), description: text('description').notNull(),
@@ -83,6 +84,13 @@ export const sqliteEvents = sqliteTable('events', {
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
// Historical slugs that still resolve (and redirect) to an event's canonical slug
export const sqliteEventSlugAliases = sqliteTable('event_slug_aliases', {
slug: text('slug').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
createdAt: text('created_at').notNull(),
});
export const sqliteTickets = sqliteTable('tickets', { export const sqliteTickets = sqliteTable('tickets', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
bookingId: text('booking_id'), // Groups multiple tickets from same booking bookingId: text('booking_id'), // Groups multiple tickets from same booking
@@ -99,6 +107,7 @@ 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(),
}); });
@@ -243,6 +252,8 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
sentAt: text('sent_at'), sentAt: text('sent_at'),
sentBy: text('sent_by').references(() => sqliteUsers.id), sentBy: text('sent_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
resendAttempts: integer('resend_attempts').notNull().default(0),
lastResentAt: text('last_resent_at'),
}); });
export const sqliteEmailSettings = sqliteTable('email_settings', { export const sqliteEmailSettings = sqliteTable('email_settings', {
@@ -384,14 +395,15 @@ 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').notNull(), startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(),
endDatetime: timestamp('end_datetime'), endDatetime: timestamp('end_datetime', { withTimezone: true }),
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'),
@@ -405,6 +417,13 @@ export const pgEvents = pgTable('events', {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
// Historical slugs that still resolve (and redirect) to an event's canonical slug
export const pgEventSlugAliases = pgTable('event_slug_aliases', {
slug: varchar('slug', { length: 255 }).primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
createdAt: timestamp('created_at').notNull(),
});
export const pgTickets = pgTable('tickets', { export const pgTickets = pgTable('tickets', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking bookingId: uuid('booking_id'), // Groups multiple tickets from same booking
@@ -421,6 +440,7 @@ 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(),
}); });
@@ -557,6 +577,8 @@ export const pgEmailLogs = pgTable('email_logs', {
sentAt: timestamp('sent_at'), sentAt: timestamp('sent_at'),
sentBy: uuid('sent_by').references(() => pgUsers.id), sentBy: uuid('sent_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
lastResentAt: timestamp('last_resent_at'),
}); });
export const pgEmailSettings = pgTable('email_settings', { export const pgEmailSettings = pgTable('email_settings', {
@@ -643,6 +665,7 @@ export const pgSiteSettings = pgTable('site_settings', {
// Export the appropriate schema based on DB_TYPE // Export the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers; export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents; export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets; export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments; export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts; export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;

View File

@@ -1342,6 +1342,61 @@ export const emailService = {
error: result.error error: result.error
}; };
}, },
/**
* Resend an email from an existing log entry
*/
async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
const log = await dbGet<any>(
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
);
if (!log) {
return { success: false, error: 'Email log not found' };
}
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
return { success: false, error: 'Email log missing required data to resend' };
}
const result = await sendEmail({
to: log.recipientEmail,
subject: log.subject,
html: log.bodyHtml,
text: undefined,
});
const now = getNow();
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: now,
errorMessage: null,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
error: result.error,
};
},
}; };
// Export the main sendEmail function for direct use // Export the main sendEmail function for direct use

View File

@@ -0,0 +1,27 @@
/**
* Convert a title into a URL-safe slug.
* Lowercases, strips accents, replaces non-alphanumerics with hyphens,
* collapses repeated hyphens, and trims leading/trailing hyphens.
*/
export function slugify(title: string): string {
return title
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Generate a slug from a title that does not collide with any of the
* provided existing slugs. Appends -2, -3, ... when needed.
*/
export function uniqueSlug(title: string, existingSlugs: string[]): string {
const base = slugify(title) || 'event';
const taken = new Set(existingSlugs);
if (!taken.has(base)) return base;
let n = 2;
while (taken.has(`${base}-${n}`)) n++;
return `${base}-${n}`;
}

View File

@@ -41,6 +41,49 @@ 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, gte, sql, desc, inArray } from 'drizzle-orm'; import { eq, and, ne, 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,7 +129,8 @@ 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)
) )
) )
); );
@@ -141,7 +142,8 @@ 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, sql } from 'drizzle-orm'; import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
@@ -287,6 +287,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');
const status = c.req.query('status'); const status = c.req.query('status');
const search = c.req.query('search');
const limit = parseInt(c.req.query('limit') || '50'); const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0'); const offset = parseInt(c.req.query('offset') || '0');
@@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
if (status) { if (status) {
conditions.push(eq((emailLogs as any).status, status)); conditions.push(eq((emailLogs as any).status, status));
} }
if (search && search.trim()) {
const term = `%${search.trim().toLowerCase()}%`;
conditions.push(or(
sql`LOWER(${(emailLogs as any).recipientEmail}) LIKE ${term}`,
sql`LOWER(COALESCE(${(emailLogs as any).recipientName}, '')) LIKE ${term}`,
sql`LOWER(${(emailLogs as any).subject}) LIKE ${term}`,
));
}
if (conditions.length > 0) { if (conditions.length > 0) {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
@@ -349,6 +358,23 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
return c.json({ log }); return c.json({ log });
}); });
// Resend email from log
emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const result = await emailService.resendFromLog(id);
if (!result.success && result.error === 'Email log not found') {
return c.json({ error: 'Email log not found' }, 404);
}
if (!result.success && result.error === 'Email log missing required data to resend') {
return c.json({ error: result.error }, 400);
}
return c.json({ success: result.success, error: result.error });
});
// Get email stats // Get email stats
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');

View File

@@ -1,10 +1,11 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js'; import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext { interface UserContext {
@@ -31,6 +32,55 @@ function normalizeEvent(event: any) {
}; };
} }
// Load every slug currently in use (canonical event slugs + historical aliases),
// optionally excluding a given event's own canonical slug + aliases.
async function getAllSlugsInUse(excludeEventId?: string): Promise<string[]> {
const eventRows = await dbAll<any>(
(db as any).select({ id: (events as any).id, slug: (events as any).slug }).from(events)
);
const aliasRows = await dbAll<any>(
(db as any).select({ eventId: (eventSlugAliases as any).eventId, slug: (eventSlugAliases as any).slug }).from(eventSlugAliases)
);
const slugs: string[] = [];
for (const row of eventRows) {
if (row.slug && row.id !== excludeEventId) slugs.push(row.slug);
}
for (const row of aliasRows) {
if (row.slug && row.eventId !== excludeEventId) slugs.push(row.slug);
}
return slugs;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Resolve an event by canonical slug, primary id, or a historical slug alias.
// Slug is checked first because Postgres rejects non-UUID strings when comparing
// against the uuid `id` column, so id lookups are guarded behind a UUID check there.
async function resolveEventByParam(param: string): Promise<any | null> {
let event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).slug, param))
);
if (!event && (!isPostgres() || UUID_PATTERN.test(param))) {
event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, param))
);
}
if (!event) {
const alias = await dbGet<any>(
(db as any).select().from(eventSlugAliases).where(eq((eventSlugAliases as any).slug, param))
);
if (alias) {
event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, alias.eventId))
);
}
}
return event || null;
}
// Custom validation error handler // Custom validation error handler
const validationHook = (result: any, c: any) => { const validationHook = (result: any, c: any) => {
if (!result.success) { if (!result.success) {
@@ -63,6 +113,7 @@ const normalizeBoolean = (val: unknown): boolean => {
const baseEventSchema = z.object({ const baseEventSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
titleEs: z.string().optional().nullable(), titleEs: z.string().optional().nullable(),
slug: z.string().optional(),
description: z.string().min(1), description: z.string().min(1),
descriptionEs: z.string().optional().nullable(), descriptionEs: z.string().optional().nullable(),
shortDescription: z.string().max(300).optional().nullable(), shortDescription: z.string().max(300).optional().nullable(),
@@ -164,13 +215,10 @@ eventsRouter.get('/', async (c) => {
return c.json({ events: eventsWithCounts }); return c.json({ events: eventsWithCounts });
}); });
// Get single event (public) // Get single event (public) - resolves by id, canonical slug, or historical alias
eventsRouter.get('/:id', async (c) => { eventsRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const param = c.req.param('id');
const event = await resolveEventByParam(param);
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
@@ -184,7 +232,7 @@ eventsRouter.get('/:id', async (c) => {
.from(tickets) .from(tickets)
.where( .where(
and( and(
eq((tickets as any).eventId, id), eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
) )
) )
@@ -201,6 +249,13 @@ 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>(
@@ -316,15 +371,21 @@ 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,
startDatetime: toDbDate(data.startDatetime), slug,
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null, startDatetime: toDbDateTz(data.startDatetime, tz),
endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -343,7 +404,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
const id = c.req.param('id'); const id = c.req.param('id');
const data = c.req.valid('json'); const data = c.req.valid('json');
const existing = await dbGet( const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id)) (db as any).select().from(events).where(eq((events as any).id, id))
); );
if (!existing) { if (!existing) {
@@ -351,14 +412,51 @@ 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 = toDbDate(data.startDatetime); updateData.startDatetime = toDbDateTz(data.startDatetime, tz);
} }
if (data.endDatetime !== undefined) { if (data.endDatetime !== undefined) {
updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null; updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null;
}
// Resolve slug: explicit admin edit takes priority, then title-derived regeneration
const oldSlug: string | null = existing.slug || null;
let newSlug: string | null = oldSlug;
if (typeof data.slug === 'string' && data.slug.trim() !== '') {
const normalized = slugify(data.slug);
if (!normalized) {
return c.json({ error: 'Invalid slug' }, 400);
}
if (normalized !== oldSlug) {
const taken = await getAllSlugsInUse(id);
if (taken.includes(normalized)) {
return c.json({ error: 'Slug already in use' }, 400);
}
newSlug = normalized;
}
} else if (data.title && slugify(data.title) !== slugify(existing.title || '')) {
const taken = await getAllSlugsInUse(id);
newSlug = uniqueSlug(data.title, taken);
}
if (newSlug && newSlug !== oldSlug) {
// If this slug was previously one of THIS event's aliases, reclaim it as canonical
await (db as any)
.delete(eventSlugAliases)
.where(and(eq((eventSlugAliases as any).slug, newSlug), eq((eventSlugAliases as any).eventId, id)));
// Preserve the old slug as an alias so existing shared links keep redirecting
if (oldSlug) {
try {
await (db as any).insert(eventSlugAliases).values({ slug: oldSlug, eventId: id, createdAt: now });
} catch (e) { /* alias may already exist */ }
}
updateData.slug = newSlug;
} }
await (db as any) await (db as any)
@@ -420,6 +518,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Delete event payment overrides // Delete event payment overrides
await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id)); await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id));
// Delete slug aliases for this event
await (db as any).delete(eventSlugAliases).where(eq((eventSlugAliases as any).eventId, id));
// Set eventId to null on email logs (they reference this event but can exist without it) // Set eventId to null on email logs (they reference this event but can exist without it)
await (db as any) await (db as any)
.update(emailLogs) .update(emailLogs)
@@ -462,11 +563,15 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
const now = getNow(); const now = getNow();
const newId = generateId(); const newId = generateId();
const duplicatedTitle = `${existing.title} (Copy)`;
const existingSlugs = await getAllSlugsInUse();
const slug = uniqueSlug(duplicatedTitle, existingSlugs);
// Create a copy with modified title and draft status // Create a copy with modified title and draft status
const duplicatedEvent = { const duplicatedEvent = {
id: newId, id: newId,
title: `${existing.title} (Copy)`, slug,
title: duplicatedTitle,
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null, titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
description: existing.description, description: existing.description,
descriptionEs: existing.descriptionEs, descriptionEs: existing.descriptionEs,
@@ -492,4 +597,44 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201); return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
}); });
// List slug aliases for an event (admin/organizer only)
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
const aliases = await dbAll<any>(
(db as any)
.select({ slug: (eventSlugAliases as any).slug, createdAt: (eventSlugAliases as any).createdAt })
.from(eventSlugAliases)
.where(eq((eventSlugAliases as any).eventId, id))
);
return c.json({ aliases });
});
// Remove a slug alias from an event (admin/organizer only)
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const slug = c.req.param('slug');
const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
await (db as any)
.delete(eventSlugAliases)
.where(and(eq((eventSlugAliases as any).eventId, id), eq((eventSlugAliases as any).slug, slug)));
return c.json({ message: 'Alias removed' });
});
export default eventsRouter; export default eventsRouter;

View File

@@ -2,7 +2,7 @@ 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, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
import { eq, and, or, sql } from 'drizzle-orm'; import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
@@ -1394,7 +1394,143 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
}, 201); }, 201);
}); });
// Get all tickets (admin) // 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
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');
const status = c.req.query('status'); const status = c.req.query('status');
@@ -1413,9 +1549,25 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
} }
const result = await dbAll(query); const ticketsList = await dbAll(query);
const ticketIds = ticketsList.map((t: any) => t.id);
return c.json({ tickets: result });
let paymentByTicketId: Record<string, any> = {};
if (ticketIds.length > 0) {
const paymentsList = await dbAll(
(db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))
);
for (const p of paymentsList as any[]) {
paymentByTicketId[p.ticketId] = p;
}
}
const ticketsWithPayment = ticketsList.map((t: any) => ({
...t,
payment: paymentByTicketId[t.id] || null,
}));
return c.json({ tickets: ticketsWithPayment });
}); });
export default ticketsRouter; export default ticketsRouter;

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -110,6 +110,10 @@ 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
@@ -162,7 +166,7 @@ export default function BookingPage() {
const soldOut = bookedCount >= capacity; const soldOut = bookedCount >= capacity;
if (soldOut) { if (soldOut) {
toast.error(t('events.details.soldOut')); toast.error(t('events.details.soldOut'));
router.push(`/events/${eventRes.event.id}`); router.push(`/events/${eventRes.event.slug}`);
return; return;
} }
@@ -217,6 +221,19 @@ 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');
@@ -261,7 +278,20 @@ 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
@@ -376,6 +406,10 @@ 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);
@@ -1015,7 +1049,7 @@ export default function BookingPage() {
<div className="section-padding bg-secondary-gray min-h-screen"> <div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl"> <div className="container-page max-w-2xl">
<Link <Link
href={`/events/${event.id}`} href={`/events/${event.slug}`}
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6" className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
> >
<ArrowLeftIcon className="w-4 h-4" /> <ArrowLeftIcon className="w-4 h-4" />
@@ -1323,13 +1357,58 @@ 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} disabled={paymentMethods.length === 0 || !agreedToTerms}
> >
{formData.paymentMethod === 'cash' {formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot') ? t('booking.form.reserveSpot')
@@ -1338,10 +1417,6 @@ 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

@@ -69,7 +69,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
} }
return ( return (
<Link href={`/events/${nextEvent.id}`} className="block group"> <Link href={`/events/${nextEvent.slug}`} className="block group">
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]"> <div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]">
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
{/* Banner */} {/* Banner */}
@@ -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>{formatDate(nextEvent.startDatetime)}</span> <span suppressHydrationWarning>{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>{fmtTime(nextEvent.startDatetime)}</span> <span suppressHydrationWarning>{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,6 +4,7 @@ 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[];
@@ -21,7 +22,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
}); });
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -7,6 +7,7 @@ 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 {
@@ -116,7 +117,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
</span> </span>
<span className="font-medium"> <span className="font-medium">
{profile?.memberSince {profile?.memberSince
? new Date(profile.memberSince).toLocaleDateString( ? parseDate(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,6 +7,7 @@ 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() {
@@ -147,7 +148,7 @@ export default function SecurityTab() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ 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';
@@ -12,6 +13,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface NextEvent { interface NextEvent {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
description: string; description: string;
@@ -63,7 +65,7 @@ export async function generateMetadata(): Promise<Metadata> {
}; };
} }
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', { const eventDate = parseDate(event.startDatetime).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -138,10 +140,10 @@ function generateNextEventJsonLd(event: NextEvent) {
(event.availableSeats ?? 0) > 0 (event.availableSeats ?? 0) > 0
? 'https://schema.org/InStock' ? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut', : 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
}, },
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
}; };
} }

View File

@@ -3,6 +3,7 @@
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';
@@ -59,19 +60,13 @@ export default function AdminBookingsPage() {
ticketsApi.getAll(), ticketsApi.getAll(),
eventsApi.getAll(), eventsApi.getAll(),
]); ]);
const ticketsWithDetails = await Promise.all( const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({
ticketsRes.tickets.map(async (ticket) => { ...ticket,
try { event: eventsRes.events.find((e) => e.id === ticket.eventId),
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id); }));
return fullTicket;
} catch { setTickets(ticketsWithEvent);
return ticket;
}
})
);
setTickets(ticketsWithDetails);
setEvents(eventsRes.events); setEvents(eventsRes.events);
} catch (error) { } catch (error) {
toast.error('Failed to load bookings'); toast.error('Failed to load bookings');
@@ -122,7 +117,7 @@ export default function AdminBookingsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -153,7 +148,8 @@ export default function AdminBookingsPage() {
} }
}; };
const getPaymentMethodLabel = (provider: string) => { const getPaymentMethodLabel = (provider: string | null) => {
if (provider == null) return '—';
const labels: Record<string, string> = { const labels: Record<string, string> = {
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event', cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer', bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
@@ -164,13 +160,13 @@ export default function AdminBookingsPage() {
return labels[provider] || provider; return labels[provider] || provider;
}; };
const getDisplayProvider = (ticket: TicketWithDetails) => { const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
if (ticket.payment?.provider) return ticket.payment.provider; if (ticket.payment?.provider) return ticket.payment.provider;
if (ticket.bookingId) { if (ticket.bookingId) {
const sibling = tickets.find(t => t.bookingId === ticket.bookingId && t.payment?.provider); const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider);
return sibling?.payment?.provider ?? 'cash'; return sibling?.payment?.provider ?? null;
} }
return 'cash'; return null;
}; };
const filteredTickets = tickets.filter((ticket) => { const filteredTickets = tickets.filter((ticket) => {

View File

@@ -7,6 +7,7 @@ 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();
@@ -44,7 +45,7 @@ export default function AdminContactsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(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,6 +3,7 @@
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';
@@ -20,6 +21,8 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
XMarkIcon, XMarkIcon,
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';
@@ -52,6 +55,11 @@ export default function AdminEmailsPage() {
const [logs, setLogs] = useState<EmailLog[]>([]); const [logs, setLogs] = useState<EmailLog[]>([]);
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 [logsSearch, setLogsSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [logsEventFilter, setLogsEventFilter] = useState('');
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null); const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
// Stats state // Stats state
@@ -210,11 +218,20 @@ export default function AdminEmailsPage() {
} }
}; };
useEffect(() => {
const handle = setTimeout(() => setDebouncedSearch(logsSearch), 300);
return () => clearTimeout(handle);
}, [logsSearch]);
useEffect(() => {
setLogsOffset(0);
}, [debouncedSearch, logsEventFilter]);
useEffect(() => { useEffect(() => {
if (activeTab === 'logs') { if (activeTab === 'logs') {
loadLogs(); loadLogs();
} }
}, [activeTab, logsOffset]); }, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -233,7 +250,13 @@ export default function AdminEmailsPage() {
const loadLogs = async () => { const loadLogs = async () => {
try { try {
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset }); const res = await emailsApi.getLogs({
limit: 20,
offset: logsOffset,
...(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);
} catch (error) { } catch (error) {
@@ -241,6 +264,27 @@ export default function AdminEmailsPage() {
} }
}; };
const handleResend = async (log: EmailLog) => {
setResendingLogId(log.id);
try {
const res = await emailsApi.resendLog(log.id);
if (res.success) {
toast.success('Email re-sent successfully');
} else {
toast.error(res.error || 'Failed to re-send email');
}
await loadLogs();
if (selectedLog?.id === log.id) {
const { log: updatedLog } = await emailsApi.getLog(log.id);
setSelectedLog(updatedLog);
}
} catch (error: any) {
toast.error(error.message || 'Failed to re-send email');
} finally {
setResendingLogId(null);
}
};
const resetTemplateForm = () => { const resetTemplateForm = () => {
setTemplateForm({ setTemplateForm({
name: '', name: '',
@@ -363,7 +407,7 @@ export default function AdminEmailsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -544,7 +588,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 ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''} Draft saved {composeForm.savedAt ? parseDate(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}>
@@ -570,7 +614,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} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} {event.title} - {parseDate(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</option> </option>
))} ))}
</select> </select>
@@ -699,6 +743,70 @@ export default function AdminEmailsPage() {
{/* Logs Tab */} {/* Logs Tab */}
{activeTab === 'logs' && ( {activeTab === 'logs' && (
<div> <div>
{/* Sub-tabs: All | Failed */}
<div className="border-b border-secondary-light-gray mb-4">
<nav className="flex gap-4">
<button
onClick={() => { setLogsSubTab('all'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
All
</button>
<button
onClick={() => { setLogsSubTab('failed'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'failed' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
Failed
{stats && stats.failed > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
{stats.failed}
</span>
)}
</button>
</nav>
</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">
@@ -714,12 +822,17 @@ 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">No emails sent yet</td></tr> <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={log.id} className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
{(log.resendAttempts ?? 0) > 0 && (
<span className="text-xs text-gray-500">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</span>
)}
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p> <p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
@@ -728,7 +841,15 @@ export default function AdminEmailsPage() {
<td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td> <td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td>
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td> <td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-1">
<button
onClick={() => handleResend(log)}
disabled={resendingLogId === log.id}
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View"> <button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />
</button> </button>
@@ -758,7 +879,7 @@ export default function AdminEmailsPage() {
{/* Mobile: Card List */} {/* Mobile: Card List */}
<div className="md:hidden space-y-2"> <div className="md:hidden space-y-2">
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">No emails sent yet</div> <div className="text-center py-10 text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}> <Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
@@ -768,7 +889,18 @@ export default function AdminEmailsPage() {
<p className="font-medium text-sm truncate">{log.subject}</p> <p className="font-medium text-sm truncate">{log.subject}</p>
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} &lt;{log.recipientEmail}&gt;</p> <p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} &lt;{log.recipientEmail}&gt;</p>
<p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p> <p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p>
{(log.resendAttempts ?? 0) > 0 && (
<p className="text-[10px] text-gray-500 mt-0.5">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</p>
)}
</div> </div>
<button
onClick={(e) => { e.stopPropagation(); handleResend(log); }}
disabled={resendingLogId === log.id}
className="p-2 hover:bg-gray-100 rounded-btn flex-shrink-0 disabled:opacity-50"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
</div> </div>
</Card> </Card>
)) ))
@@ -938,12 +1070,26 @@ export default function AdminEmailsPage() {
{selectedLog.errorMessage && ( {selectedLog.errorMessage && (
<span className="text-xs text-red-500">- {selectedLog.errorMessage}</span> <span className="text-xs text-red-500">- {selectedLog.errorMessage}</span>
)} )}
{(selectedLog.resendAttempts ?? 0) > 0 && (
<span className="text-xs text-gray-500">Re-sent {selectedLog.resendAttempts} time{(selectedLog.resendAttempts ?? 0) !== 1 ? 's' : ''}{selectedLog.lastResentAt ? ` (${formatDate(selectedLog.lastResentAt)})` : ''}</span>
)}
</div> </div>
</div> </div>
<button onClick={() => setSelectedLog(null)} <div className="flex items-center gap-1 flex-shrink-0">
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0"> <button
<XMarkIcon className="w-5 h-5" /> onClick={() => handleResend(selectedLog)}
</button> disabled={resendingLogId === selectedLog.id}
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50 flex items-center gap-1.5 text-sm"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === selectedLog.id && 'animate-spin')} />
Re-send
</button>
<button onClick={() => setSelectedLog(null)}
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>
</div> </div>
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50"> <div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
<p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p> <p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p>

View File

@@ -5,6 +5,7 @@ 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';
@@ -38,9 +39,11 @@ 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';
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments'; type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
@@ -68,7 +71,7 @@ export default function AdminEventDetailPage() {
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false); const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
const [showManualTicketModal, setShowManualTicketModal] = useState(false); const [showManualTicketModal, setShowManualTicketModal] = useState(false);
const [showStats, setShowStats] = useState(true); const [showStats, setShowStats, toggleStats] = useStatsPrivacy();
const [showNoteModal, setShowNoteModal] = useState(false); const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null); const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
@@ -87,6 +90,14 @@ 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)
@@ -211,32 +222,9 @@ export default function AdminEventDetailPage() {
} }
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
weekday: 'long', const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
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') {
@@ -375,6 +363,30 @@ 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);
@@ -465,7 +477,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 ? formatTime(event.startDatetime) : '', eventTime: event ? fmtTime(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) : '',
@@ -537,7 +549,9 @@ 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 revenue = (confirmedCount + checkedInCount) * event.price; const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
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 },
@@ -572,11 +586,15 @@ 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; {formatTime(event.startDatetime)}</p> <p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {fmtTime(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">
<Link href={`/events/${event.id}`} target="_blank"> <Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</Button>
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" /> <EyeIcon className="w-4 h-4 mr-1.5" />
View Public View Public
@@ -600,13 +618,13 @@ export default function AdminEventDetailPage() {
</button> </button>
} }
> >
<DropdownItem onClick={() => { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}> <DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public <EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem> </DropdownItem>
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> <DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event <PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem> </DropdownItem>
<DropdownItem onClick={() => { setShowStats(v => !v); setMobileHeaderMenuOpen(false); }}> <DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />} {showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
{showStats ? 'Hide Stats' : 'Show Stats'} {showStats ? 'Hide Stats' : 'Show Stats'}
</DropdownItem> </DropdownItem>
@@ -618,7 +636,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)} {formatTime(event.startDatetime)}{event.endDatetime && ` ${formatTime(event.endDatetime)}`} {formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` ${fmtTime(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" />
@@ -628,10 +646,12 @@ export default function AdminEventDetailPage() {
<CurrencyDollarIcon className="w-3.5 h-3.5" /> <CurrencyDollarIcon className="w-3.5 h-3.5" />
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)} {event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
</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"> {showStats && (
<UsersIcon className="w-3.5 h-3.5" /> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
{confirmedCount + checkedInCount}/{event.capacity} <UsersIcon className="w-3.5 h-3.5" />
</span> {confirmedCount + checkedInCount}/{event.capacity}
</span>
)}
</div> </div>
{/* ============= STATS ROW ============= */} {/* ============= STATS ROW ============= */}
@@ -771,7 +791,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">{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}</p> <p className="text-sm text-gray-600">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -902,6 +922,9 @@ 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') && (
@@ -999,15 +1022,20 @@ 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">
{getStatusBadge(ticket.status, true)} <div className="flex items-center gap-1 flex-wrap">
{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">
{new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })} {parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
</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">
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} {parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
</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">
@@ -1059,14 +1087,17 @@ 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"> <div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
{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">
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} {parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
{ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`} {ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{primary && ( {primary && (
@@ -1223,8 +1254,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 ? (
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { parseDate(ticket.checkinAt).toLocaleString(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: EVENT_TIMEZONE,
}) })
) : ( ) : (
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
@@ -1286,7 +1317,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 ${new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}` ? `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 })}`
: 'Not checked in'} : 'Not checked in'}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -1830,6 +1861,16 @@ 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>
@@ -2039,6 +2080,86 @@ 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

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api'; import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
@@ -14,8 +14,10 @@ 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 { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
@@ -26,9 +28,11 @@ export default function AdminEventsPage() {
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null); const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null); const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
title: string; title: string;
titleEs: string; titleEs: string;
slug: string;
description: string; description: string;
descriptionEs: string; descriptionEs: string;
shortDescription: string; shortDescription: string;
@@ -47,6 +51,7 @@ export default function AdminEventsPage() {
}>({ }>({
title: '', title: '',
titleEs: '', titleEs: '',
slug: '',
description: '', description: '',
descriptionEs: '', descriptionEs: '',
shortDescription: '', shortDescription: '',
@@ -111,8 +116,9 @@ export default function AdminEventsPage() {
}; };
const resetForm = () => { const resetForm = () => {
setSlugAliases([]);
setFormData({ setFormData({
title: '', titleEs: '', description: '', descriptionEs: '', title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '', shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const, price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
@@ -122,18 +128,45 @@ export default function AdminEventsPage() {
}; };
const isoToLocalDatetime = (isoString: string): string => { const isoToLocalDatetime = (isoString: string): string => {
const date = new Date(isoString); const date = parseDate(isoString);
const year = date.getFullYear(); const parts = new Intl.DateTimeFormat('en-US', {
const month = String(date.getMonth() + 1).padStart(2, '0'); timeZone: EVENT_TIMEZONE,
const day = String(date.getDate()).padStart(2, '0'); year: 'numeric',
const hours = String(date.getHours()).padStart(2, '0'); month: '2-digit',
const minutes = String(date.getMinutes()).padStart(2, '0'); day: '2-digit',
return `${year}-${month}-${day}T${hours}:${minutes}`; hour: '2-digit',
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 || '', title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
description: event.description, descriptionEs: event.descriptionEs || '', description: event.description, descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '', shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime), startDatetime: isoToLocalDatetime(event.startDatetime),
@@ -146,6 +179,7 @@ export default function AdminEventsPage() {
}); });
setEditingEvent(event); setEditingEvent(event);
setShowForm(true); setShowForm(true);
loadSlugAliases(event.id);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -162,12 +196,12 @@ export default function AdminEventsPage() {
setSaving(false); setSaving(false);
return; return;
} }
const eventData = { const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined, title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined,
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined, shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
startDatetime: new Date(formData.startDatetime).toISOString(), startDatetime: formData.startDatetime,
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined, endDatetime: formData.endDatetime || 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,
@@ -175,6 +209,8 @@ export default function AdminEventsPage() {
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
}; };
if (editingEvent) { if (editingEvent) {
// Only send slug when editing so creates still auto-generate from title
eventData.slug = formData.slug || undefined;
await eventsApi.update(editingEvent.id, eventData); await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated'); toast.success('Event updated');
} else { } else {
@@ -213,7 +249,7 @@ export default function AdminEventsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(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',
}); });
}; };
@@ -291,6 +327,38 @@ export default function AdminEventsPage() {
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} /> onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
</div> </div>
{editingEvent && (
<div>
<Input label="URL Slug" value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="auto-generated from title" />
<p className="text-xs text-gray-500 mt-1">
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
. Changing the slug keeps the old one as a redirecting alias.
</p>
{slugAliases.length > 0 && (
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
<p className="text-sm font-medium mb-2">URL aliases</p>
<p className="text-xs text-gray-500 mb-2">
Old URLs that still redirect to the current slug. Removing one breaks those links.
</p>
<ul className="space-y-1">
{slugAliases.map((alias) => (
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
<span className="font-mono truncate">/events/{alias.slug}</span>
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
title="Remove alias">
<TrashIcon className="w-4 h-4" />
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1">Description (English)</label> <label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea value={formData.description} <textarea value={formData.description}
@@ -458,7 +526,11 @@ export default function AdminEventsPage() {
</tr> </tr>
) : ( ) : (
events.map((event) => ( events.map((event) => (
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}> <tr
key={event.id}
onClick={() => router.push(`/admin/events/${event.id}`)}
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
@@ -492,7 +564,7 @@ export default function AdminEventsPage() {
)} )}
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{event.status === 'draft' && ( {event.status === 'draft' && (
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}> <Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
@@ -561,7 +633,11 @@ export default function AdminEventsPage() {
</div> </div>
) : ( ) : (
events.map((event) => ( events.map((event) => (
<Card key={event.id} className={clsx("p-3", featuredEventId === event.id && "ring-2 ring-amber-300")}> <Card
key={event.id}
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
onClick={() => router.push(`/admin/events/${event.id}`)}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title} <img src={event.bannerUrl} alt={event.title}
@@ -590,7 +666,7 @@ export default function AdminEventsPage() {
</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-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p> <p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`} <Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center"> className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />

View File

@@ -3,6 +3,7 @@
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 {
@@ -108,7 +109,7 @@ export default function AdminGalleryPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -4,6 +4,7 @@ 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';
@@ -158,7 +159,7 @@ export default function AdminLegalPagesPage() {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
try { try {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -15,6 +15,7 @@ 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();
@@ -30,7 +31,7 @@ export default function AdminDashboardPage() {
}, []); }, []);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(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,6 +3,7 @@
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';
@@ -203,7 +204,7 @@ export default function AdminPaymentsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
@@ -280,6 +281,22 @@ 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) {
@@ -287,7 +304,7 @@ export default function AdminPaymentsPage() {
} }
// Count all pending payments with the same bookingId // Count all pending payments with the same bookingId
const bookingPayments = pendingApprovalPayments.filter( const bookingPayments = visiblePendingApprovalPayments.filter(
p => p.ticket?.bookingId === payment.ticket?.bookingId p => p.ticket?.bookingId === payment.ticket?.bookingId
); );
@@ -325,7 +342,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(pendingApprovalPayments); const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments);
if (loading) { if (loading) {
return ( return (
@@ -619,8 +636,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>
{pendingApprovalPayments.length !== pendingApprovalBookingsCount && ( {visiblePendingApprovalPayments.length !== pendingApprovalBookingsCount && (
<p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p> <p className="text-xs text-gray-400">({visiblePendingApprovalPayments.length} tickets)</p>
)} )}
</div> </div>
</div> </div>
@@ -669,8 +686,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'}
{pendingApprovalPayments.length > 0 && ( {visiblePendingApprovalPayments.length > 0 && (
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span> <span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{visiblePendingApprovalPayments.length}</span>
)} )}
</button> </button>
<button onClick={() => setActiveTab('all')} <button onClick={() => setActiveTab('all')}
@@ -684,7 +701,7 @@ export default function AdminPaymentsPage() {
{/* Pending Approval Tab */} {/* Pending Approval Tab */}
{activeTab === 'pending_approval' && ( {activeTab === 'pending_approval' && (
<> <>
{pendingApprovalPayments.length === 0 ? ( {visiblePendingApprovalPayments.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">
@@ -695,7 +712,7 @@ export default function AdminPaymentsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{pendingApprovalPayments.map((payment) => { {visiblePendingApprovalPayments.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,6 +18,7 @@ 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 ───────────────────────────────────────────────────
@@ -324,7 +325,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 ${new Date(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}` ? `Checked in at ${parseDate(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}${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.',
@@ -765,7 +766,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' }), time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
ticketId: scanResult.validation!.ticket!.id, ticketId: scanResult.validation!.ticket!.id,
}, },
...prev.slice(0, 19), ...prev.slice(0, 19),
@@ -796,7 +797,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' }), time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
ticketId: searchDetailValidation!.ticket!.id, ticketId: searchDetailValidation!.ticket!.id,
}, },
...prev.slice(0, 19), ...prev.slice(0, 19),

View File

@@ -4,6 +4,7 @@ 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';
@@ -319,7 +320,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">
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { {parseDate(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

@@ -3,6 +3,7 @@
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';
@@ -107,7 +108,7 @@ export default function AdminTicketsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(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,6 +3,7 @@
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';
@@ -104,7 +105,7 @@ export default function AdminUsersPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(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-inside my-3; @apply list-disc list-outside pl-6 my-3;
} }
.ProseMirror ol { .ProseMirror ol {
@apply list-decimal list-inside my-3; @apply list-decimal list-outside pl-6 my-3;
} }
.ProseMirror li { .ProseMirror li {

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -10,6 +11,7 @@ interface LlmsFaq {
interface LlmsEvent { interface LlmsEvent {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
shortDescription?: string; shortDescription?: string;
@@ -56,7 +58,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 new Date(dateStr).toLocaleDateString('en-US', { return parseDate(dateStr).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -66,7 +68,7 @@ function formatEventDate(dateStr: string): string {
} }
function formatEventTime(dateStr: string): string { function formatEventTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-US', { return parseDate(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
@@ -80,7 +82,7 @@ function formatPrice(price: number, currency: string): string {
} }
function formatISODate(dateStr: string): string { function formatISODate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-CA', { return parseDate(dateStr).toLocaleDateString('en-CA', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -89,7 +91,7 @@ function formatISODate(dateStr: string): string {
} }
function formatISOTime(dateStr: string): string { function formatISOTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-GB', { return parseDate(dateStr).toLocaleTimeString('en-GB', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
@@ -192,7 +194,7 @@ export async function GET() {
if (nextEvent.availableSeats !== undefined) { if (nextEvent.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`); lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
} }
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`); lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.slug}`);
if (nextEvent.shortDescription) { if (nextEvent.shortDescription) {
lines.push(`- Description: ${nextEvent.shortDescription}`); lines.push(`- Description: ${nextEvent.shortDescription}`);
} }
@@ -225,7 +227,7 @@ export async function GET() {
if (event.availableSeats !== undefined) { if (event.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${event.availableSeats}`); lines.push(`- Capacity Remaining: ${event.availableSeats}`);
} }
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`); lines.push(`- Tickets URL: ${siteUrl}/events/${event.slug}`);
lines.push(''); lines.push('');
} }
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
export function useStatsPrivacy() {
const [showStats, setShowStatsState] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
setShowStatsState(stored !== 'true');
}
} catch {
// ignore
}
}, []);
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
setShowStatsState((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
try {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, String(!next));
}
} catch {
// ignore
}
return next;
});
}, []);
const toggleStats = useCallback(() => {
setShowStats((prev) => !prev);
}, [setShowStats]);
return [showStats, setShowStats, toggleStats] as const;
}

View File

@@ -117,7 +117,11 @@
"rucOptional": "Optional - for invoice", "rucOptional": "Optional - for invoice",
"reserveSpot": "Reserve My Spot", "reserveSpot": "Reserve My Spot",
"proceedPayment": "Proceed to Payment", "proceedPayment": "Proceed to Payment",
"termsNote": "By booking, you agree to our terms and conditions.", "termsAgreePart1": "I agree to the ",
"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",
@@ -129,7 +133,8 @@
"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": {
@@ -317,6 +322,11 @@
"refund": "Refund Policy" "refund": "Refund Policy"
} }
}, },
"legalPage": {
"backToHome": "Back to Home",
"lastUpdated": "Last updated: {date}",
"backToTop": "Back to top"
},
"linktree": { "linktree": {
"tagline": "Language Exchange Community", "tagline": "Language Exchange Community",
"nextEvent": "Next Event", "nextEvent": "Next Event",

View File

@@ -117,7 +117,11 @@
"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",
"termsNote": "Al reservar, aceptas nuestros términos y condiciones.", "termsAgreePart1": "Acepto los ",
"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",
@@ -129,7 +133,8 @@
"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": {
@@ -317,6 +322,11 @@
"refund": "Política de Reembolso" "refund": "Política de Reembolso"
} }
}, },
"legalPage": {
"backToHome": "Volver al inicio",
"lastUpdated": "Última actualización: {date}",
"backToTop": "Volver arriba"
},
"linktree": { "linktree": {
"tagline": "Comunidad de Intercambio de Idiomas", "tagline": "Comunidad de Intercambio de Idiomas",
"nextEvent": "Próximo Evento", "nextEvent": "Próximo Evento",

View File

@@ -67,6 +67,12 @@ export const eventsApi = {
duplicate: (id: string) => duplicate: (id: string) =>
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }), fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
getSlugAliases: (id: string) =>
fetchApi<{ aliases: { slug: string; createdAt: string }[] }>(`/api/events/${id}/slug-aliases`),
deleteSlugAlias: (id: string, slug: string) =>
fetchApi<{ message: string }>(`/api/events/${id}/slug-aliases/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
}; };
// Tickets API // Tickets API
@@ -179,6 +185,20 @@ 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 }>(
@@ -482,17 +502,23 @@ export const emailsApi = {
}), }),
// Logs // Logs
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => { getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId); if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.limit) query.set('limit', params.limit.toString()); if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString()); if (params?.offset) query.set('offset', params.offset.toString());
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`); return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
}, },
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`), getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
resendLog: (id: string) =>
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
method: 'POST',
}),
getStats: (eventId?: string) => { getStats: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : ''; const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`); return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
@@ -505,6 +531,7 @@ export const emailsApi = {
// Types // Types
export interface Event { export interface Event {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
description: string; description: string;
@@ -545,6 +572,7 @@ 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;
@@ -792,6 +820,8 @@ export interface EmailLog {
sentAt?: string; sentAt?: string;
sentBy?: string; sentBy?: string;
createdAt: string; createdAt: string;
resendAttempts?: number;
lastResentAt?: string;
} }
export interface EmailStats { export interface EmailStats {

View File

@@ -4,9 +4,14 @@
// 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).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const EVENT_TIMEZONE = 'America/Asuncion'; export const EVENT_TIMEZONE = 'America/Asuncion';
type Locale = 'en' | 'es'; type Locale = 'en' | 'es';
@@ -14,11 +19,29 @@ 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 new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -30,7 +53,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 new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -43,7 +66,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 new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -55,7 +78,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 new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -67,7 +90,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 new Date(dateStr).toLocaleTimeString(pickLocale(locale), { return parseDate(dateStr).toLocaleTimeString(pickLocale(locale), {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: EVENT_TIMEZONE, timeZone: EVENT_TIMEZONE,
@@ -78,7 +101,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 new Date(dateStr).toLocaleString(pickLocale(locale), { return parseDate(dateStr).toLocaleString(pickLocale(locale), {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -92,7 +115,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 new Date(dateStr).toLocaleString(pickLocale(locale), { return parseDate(dateStr).toLocaleString(pickLocale(locale), {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',