Compare commits
19 Commits
a5d97d65e1
...
1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| c0315a705d | |||
|
|
1b2463f4bc | ||
| fbc437a670 | |||
|
|
d09c87a5a5 | ||
| e0f0700398 | |||
|
|
69768077e5 | ||
|
|
ecd2a7d009 | ||
|
|
0f7573c934 | ||
|
|
a8b72b47b1 | ||
| defd9685e0 | |||
|
|
22e9254f42 | ||
|
|
2cabd8c92f | ||
|
|
622bb5171c | ||
|
|
55516ef1e7 | ||
|
|
3dfb1689ad | ||
| 1ed62b0d3f | |||
|
|
f8ebc3760d | ||
| 91de6df04d | |||
|
|
4da26e7ef1 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,6 +37,8 @@ backend/uploads/
|
||||
# Tooling
|
||||
.turbo/
|
||||
.cursor/
|
||||
.agents/
|
||||
skills-lock.json
|
||||
.npm-cache/
|
||||
|
||||
# OS
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -43,11 +43,13 @@ function exportSqlite(outputPath: string): void {
|
||||
|
||||
function exportPostgres(outputPath: string): void {
|
||||
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
|
||||
const outFd = openSync(outputPath, 'w');
|
||||
try {
|
||||
const result = spawnSync(
|
||||
'pg_dump',
|
||||
['--clean', '--if-exists', connString],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ['ignore', outFd, 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
);
|
||||
@@ -63,8 +65,10 @@ function exportPostgres(outputPath: string): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, result.stdout);
|
||||
console.log(`Exported to ${outputPath}`);
|
||||
} finally {
|
||||
closeSync(outFd);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import { db } from './index.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db, dbAll, events } from './index.js';
|
||||
import { sql, eq } from 'drizzle-orm';
|
||||
import { uniqueSlug } from '../lib/slugify.js';
|
||||
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
console.log(`Database type: ${dbType}`);
|
||||
@@ -111,6 +112,23 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add slug column to events (backfilled below)
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN slug TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`);
|
||||
} catch (e) { /* index may already exist */ }
|
||||
|
||||
// Historical slugs that still resolve (and redirect) to an event's canonical slug
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS event_slug_aliases (
|
||||
slug TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL REFERENCES events(id),
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -174,6 +192,11 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
|
||||
} 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)
|
||||
// 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`
|
||||
CREATE TABLE IF NOT EXISTS email_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -526,8 +556,8 @@ async function migrate() {
|
||||
description_es TEXT,
|
||||
short_description VARCHAR(300),
|
||||
short_description_es VARCHAR(300),
|
||||
start_datetime TIMESTAMP NOT NULL,
|
||||
end_datetime TIMESTAMP,
|
||||
start_datetime TIMESTAMPTZ NOT NULL,
|
||||
end_datetime TIMESTAMPTZ,
|
||||
location VARCHAR(500) NOT NULL,
|
||||
location_url VARCHAR(500),
|
||||
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)`);
|
||||
} 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`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
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`);
|
||||
} 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`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
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`
|
||||
CREATE TABLE IF NOT EXISTS email_settings (
|
||||
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!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export const sqliteInvoices = sqliteTable('invoices', {
|
||||
|
||||
export const sqliteEvents = sqliteTable('events', {
|
||||
id: text('id').primaryKey(),
|
||||
slug: text('slug').unique(),
|
||||
title: text('title').notNull(),
|
||||
titleEs: text('title_es'),
|
||||
description: text('description').notNull(),
|
||||
@@ -83,6 +84,13 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Historical slugs that still resolve (and redirect) to an event's canonical slug
|
||||
export const sqliteEventSlugAliases = sqliteTable('event_slug_aliases', {
|
||||
slug: text('slug').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteTickets = sqliteTable('tickets', {
|
||||
id: text('id').primaryKey(),
|
||||
bookingId: text('booking_id'), // Groups multiple tickets from same booking
|
||||
@@ -99,6 +107,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
adminNote: text('admin_note'),
|
||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -243,6 +252,8 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
|
||||
sentAt: text('sent_at'),
|
||||
sentBy: text('sent_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
resendAttempts: integer('resend_attempts').notNull().default(0),
|
||||
lastResentAt: text('last_resent_at'),
|
||||
});
|
||||
|
||||
export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||
@@ -384,14 +395,15 @@ export const pgInvoices = pgTable('invoices', {
|
||||
|
||||
export const pgEvents = pgTable('events', {
|
||||
id: uuid('id').primaryKey(),
|
||||
slug: varchar('slug', { length: 255 }).unique(),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
titleEs: varchar('title_es', { length: 255 }),
|
||||
description: pgText('description').notNull(),
|
||||
descriptionEs: pgText('description_es'),
|
||||
shortDescription: varchar('short_description', { length: 300 }),
|
||||
shortDescriptionEs: varchar('short_description_es', { length: 300 }),
|
||||
startDatetime: timestamp('start_datetime').notNull(),
|
||||
endDatetime: timestamp('end_datetime'),
|
||||
startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(),
|
||||
endDatetime: timestamp('end_datetime', { withTimezone: true }),
|
||||
location: varchar('location', { length: 500 }).notNull(),
|
||||
locationUrl: varchar('location_url', { length: 500 }),
|
||||
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
@@ -405,6 +417,13 @@ export const pgEvents = pgTable('events', {
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Historical slugs that still resolve (and redirect) to an event's canonical slug
|
||||
export const pgEventSlugAliases = pgTable('event_slug_aliases', {
|
||||
slug: varchar('slug', { length: 255 }).primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgTickets = pgTable('tickets', {
|
||||
id: uuid('id').primaryKey(),
|
||||
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking
|
||||
@@ -421,6 +440,7 @@ export const pgTickets = pgTable('tickets', {
|
||||
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
|
||||
qrCode: varchar('qr_code', { length: 255 }),
|
||||
adminNote: pgText('admin_note'),
|
||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -557,6 +577,8 @@ export const pgEmailLogs = pgTable('email_logs', {
|
||||
sentAt: timestamp('sent_at'),
|
||||
sentBy: uuid('sent_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
|
||||
lastResentAt: timestamp('last_resent_at'),
|
||||
});
|
||||
|
||||
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 const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
||||
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
||||
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
|
||||
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
|
||||
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
|
||||
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
|
||||
|
||||
@@ -1342,6 +1342,61 @@ export const emailService = {
|
||||
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
|
||||
|
||||
27
backend/src/lib/slugify.ts
Normal file
27
backend/src/lib/slugify.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Convert a title into a URL-safe slug.
|
||||
* Lowercases, strips accents, replaces non-alphanumerics with hyphens,
|
||||
* collapses repeated hyphens, and trims leading/trailing hyphens.
|
||||
*/
|
||||
export function slugify(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a title that does not collide with any of the
|
||||
* provided existing slugs. Appends -2, -3, ... when needed.
|
||||
*/
|
||||
export function uniqueSlug(title: string, existingSlugs: string[]): string {
|
||||
const base = slugify(title) || 'event';
|
||||
const taken = new Set(existingSlugs);
|
||||
if (!taken.has(base)) return base;
|
||||
let n = 2;
|
||||
while (taken.has(`${base}-${n}`)) n++;
|
||||
return `${base}-${n}`;
|
||||
}
|
||||
@@ -41,6 +41,49 @@ export function toDbDate(date: Date | string): string | Date {
|
||||
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.
|
||||
* - SQLite: returns boolean (true/false) for mode: 'boolean'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
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 { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -129,7 +129,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||
.where(
|
||||
and(
|
||||
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(
|
||||
and(
|
||||
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)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
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 { getNow, generateId } from '../lib/utils.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) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
const status = c.req.query('status');
|
||||
const search = c.req.query('search');
|
||||
const limit = parseInt(c.req.query('limit') || '50');
|
||||
const offset = parseInt(c.req.query('offset') || '0');
|
||||
|
||||
@@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
if (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) {
|
||||
query = query.where(and(...conditions));
|
||||
@@ -349,6 +358,23 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
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
|
||||
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
|
||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, 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';
|
||||
|
||||
interface UserContext {
|
||||
@@ -31,6 +32,55 @@ function normalizeEvent(event: any) {
|
||||
};
|
||||
}
|
||||
|
||||
// Load every slug currently in use (canonical event slugs + historical aliases),
|
||||
// optionally excluding a given event's own canonical slug + aliases.
|
||||
async function getAllSlugsInUse(excludeEventId?: string): Promise<string[]> {
|
||||
const eventRows = await dbAll<any>(
|
||||
(db as any).select({ id: (events as any).id, slug: (events as any).slug }).from(events)
|
||||
);
|
||||
const aliasRows = await dbAll<any>(
|
||||
(db as any).select({ eventId: (eventSlugAliases as any).eventId, slug: (eventSlugAliases as any).slug }).from(eventSlugAliases)
|
||||
);
|
||||
const slugs: string[] = [];
|
||||
for (const row of eventRows) {
|
||||
if (row.slug && row.id !== excludeEventId) slugs.push(row.slug);
|
||||
}
|
||||
for (const row of aliasRows) {
|
||||
if (row.slug && row.eventId !== excludeEventId) slugs.push(row.slug);
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Resolve an event by canonical slug, primary id, or a historical slug alias.
|
||||
// Slug is checked first because Postgres rejects non-UUID strings when comparing
|
||||
// against the uuid `id` column, so id lookups are guarded behind a UUID check there.
|
||||
async function resolveEventByParam(param: string): Promise<any | null> {
|
||||
let event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).slug, param))
|
||||
);
|
||||
|
||||
if (!event && (!isPostgres() || UUID_PATTERN.test(param))) {
|
||||
event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, param))
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
const alias = await dbGet<any>(
|
||||
(db as any).select().from(eventSlugAliases).where(eq((eventSlugAliases as any).slug, param))
|
||||
);
|
||||
if (alias) {
|
||||
event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, alias.eventId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return event || null;
|
||||
}
|
||||
|
||||
// Custom validation error handler
|
||||
const validationHook = (result: any, c: any) => {
|
||||
if (!result.success) {
|
||||
@@ -63,6 +113,7 @@ const normalizeBoolean = (val: unknown): boolean => {
|
||||
const baseEventSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
titleEs: z.string().optional().nullable(),
|
||||
slug: z.string().optional(),
|
||||
description: z.string().min(1),
|
||||
descriptionEs: z.string().optional().nullable(),
|
||||
shortDescription: z.string().max(300).optional().nullable(),
|
||||
@@ -164,13 +215,10 @@ eventsRouter.get('/', async (c) => {
|
||||
return c.json({ events: eventsWithCounts });
|
||||
});
|
||||
|
||||
// Get single event (public)
|
||||
// Get single event (public) - resolves by id, canonical slug, or historical alias
|
||||
eventsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
);
|
||||
const param = c.req.param('id');
|
||||
const event = await resolveEventByParam(param);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
@@ -184,7 +232,7 @@ eventsRouter.get('/:id', async (c) => {
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, id),
|
||||
eq((tickets as any).eventId, event.id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
||||
const ticketCount = await dbGet<any>(
|
||||
@@ -316,15 +371,21 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
const tz = await getSiteTimezone();
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
|
||||
// Generate a unique slug from the title (manual slug is honored on update, not create)
|
||||
const existingSlugs = await getAllSlugsInUse();
|
||||
const slug = uniqueSlug(data.title, existingSlugs);
|
||||
|
||||
const newEvent = {
|
||||
id,
|
||||
...dbData,
|
||||
startDatetime: toDbDate(data.startDatetime),
|
||||
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null,
|
||||
slug,
|
||||
startDatetime: toDbDateTz(data.startDatetime, tz),
|
||||
endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -343,7 +404,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
const id = c.req.param('id');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const existing = await dbGet(
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
);
|
||||
if (!existing) {
|
||||
@@ -351,14 +412,51 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const tz = await getSiteTimezone();
|
||||
// Convert data for database compatibility
|
||||
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
||||
// Slug changes are handled explicitly below to manage aliases
|
||||
delete updateData.slug;
|
||||
// Convert datetime fields if present
|
||||
if (data.startDatetime) {
|
||||
updateData.startDatetime = toDbDate(data.startDatetime);
|
||||
updateData.startDatetime = toDbDateTz(data.startDatetime, tz);
|
||||
}
|
||||
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)
|
||||
@@ -420,6 +518,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
// Delete event payment overrides
|
||||
await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id));
|
||||
|
||||
// Delete slug aliases for this event
|
||||
await (db as any).delete(eventSlugAliases).where(eq((eventSlugAliases as any).eventId, id));
|
||||
|
||||
// Set eventId to null on email logs (they reference this event but can exist without it)
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
@@ -462,11 +563,15 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
|
||||
const now = getNow();
|
||||
const newId = generateId();
|
||||
const duplicatedTitle = `${existing.title} (Copy)`;
|
||||
const existingSlugs = await getAllSlugsInUse();
|
||||
const slug = uniqueSlug(duplicatedTitle, existingSlugs);
|
||||
|
||||
// Create a copy with modified title and draft status
|
||||
const duplicatedEvent = {
|
||||
id: newId,
|
||||
title: `${existing.title} (Copy)`,
|
||||
slug,
|
||||
title: duplicatedTitle,
|
||||
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
|
||||
description: existing.description,
|
||||
descriptionEs: existing.descriptionEs,
|
||||
@@ -492,4 +597,44 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
|
||||
});
|
||||
|
||||
// List slug aliases for an event (admin/organizer only)
|
||||
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
);
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const aliases = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ slug: (eventSlugAliases as any).slug, createdAt: (eventSlugAliases as any).createdAt })
|
||||
.from(eventSlugAliases)
|
||||
.where(eq((eventSlugAliases as any).eventId, id))
|
||||
);
|
||||
|
||||
return c.json({ aliases });
|
||||
});
|
||||
|
||||
// Remove a slug alias from an event (admin/organizer only)
|
||||
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const slug = c.req.param('slug');
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
);
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.delete(eventSlugAliases)
|
||||
.where(and(eq((eventSlugAliases as any).eventId, id), eq((eventSlugAliases as any).slug, slug)));
|
||||
|
||||
return c.json({ message: 'Alias removed' });
|
||||
});
|
||||
|
||||
export default eventsRouter;
|
||||
|
||||
@@ -1394,6 +1394,142 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Admin invite guest ticket (free, confirmed, not counted in revenue)
|
||||
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||
eventId: z.string(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().optional().or(z.literal('')),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
adminNote: z.string().max(1000).optional(),
|
||||
})), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const adminUser = (c as any).get('user');
|
||||
|
||||
// Find or create user (use placeholder email if none provided)
|
||||
const attendeeEmail = data.email && data.email.trim()
|
||||
? data.email.trim()
|
||||
: `guest-${generateId()}@guestinvite.local`;
|
||||
|
||||
const fullName = data.lastName && data.lastName.trim()
|
||||
? `${data.firstName} ${data.lastName}`.trim()
|
||||
: data.firstName;
|
||||
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
const userId = generateId();
|
||||
user = {
|
||||
id: userId,
|
||||
email: attendeeEmail,
|
||||
password: '',
|
||||
name: fullName,
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
}
|
||||
|
||||
// Check for existing active ticket (only for real emails, not placeholder)
|
||||
if (data.email && data.email.trim()) {
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).userId, user.id),
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
);
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: data.firstName,
|
||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'confirmed',
|
||||
isGuest: 1,
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
adminNote: data.adminNote || null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(tickets).values(newTicket);
|
||||
|
||||
// Create a $0 payment record to track the invite
|
||||
const paymentId = generateId();
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: 'cash',
|
||||
amount: 0,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: 'Guest invite',
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
|
||||
// Send booking confirmation email if a real email was provided
|
||||
if (data.email && data.email.trim()) {
|
||||
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||
if (result.success) {
|
||||
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
...newTicket,
|
||||
event: {
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
},
|
||||
},
|
||||
payment: newPayment,
|
||||
message: 'Guest ticket created successfully',
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Get all tickets (admin) - includes payment for each ticket
|
||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
BIN
frontend/public/images/icon-192.png
Normal file
BIN
frontend/public/images/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
BIN
frontend/public/images/icon-512.png
Normal file
BIN
frontend/public/images/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -110,6 +110,10 @@ export default function BookingPage() {
|
||||
|
||||
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}$/;
|
||||
|
||||
// Format RUC input: digits only, max 10
|
||||
@@ -162,7 +166,7 @@ export default function BookingPage() {
|
||||
const soldOut = bookedCount >= capacity;
|
||||
if (soldOut) {
|
||||
toast.error(t('events.details.soldOut'));
|
||||
router.push(`/events/${eventRes.event.id}`);
|
||||
router.push(`/events/${eventRes.event.slug}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -217,6 +221,19 @@ export default function BookingPage() {
|
||||
}
|
||||
}, [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 fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
@@ -261,7 +278,20 @@ export default function BookingPage() {
|
||||
|
||||
setErrors(newErrors);
|
||||
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
|
||||
@@ -376,6 +406,10 @@ export default function BookingPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!agreedToTerms) {
|
||||
setTermsError(t('booking.form.errors.termsRequired'));
|
||||
return;
|
||||
}
|
||||
if (!event || !validateForm()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -1015,7 +1049,7 @@ export default function BookingPage() {
|
||||
<div className="section-padding bg-secondary-gray min-h-screen">
|
||||
<div className="container-page max-w-2xl">
|
||||
<Link
|
||||
href={`/events/${event.id}`}
|
||||
href={`/events/${event.slug}`}
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
@@ -1323,13 +1357,58 @@ export default function BookingPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={submitting}
|
||||
disabled={paymentMethods.length === 0}
|
||||
disabled={paymentMethods.length === 0 || !agreedToTerms}
|
||||
>
|
||||
{formData.paymentMethod === 'cash'
|
||||
? t('booking.form.reserveSpot')
|
||||
@@ -1338,10 +1417,6 @@ export default function BookingPage() {
|
||||
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
|
||||
}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
{t('booking.form.termsNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/events/${nextEvent.id}`} className="block group">
|
||||
<Link href={`/events/${nextEvent.slug}`} className="block group">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Banner */}
|
||||
@@ -102,11 +102,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
||||
<div className="mt-4 md:mt-5 space-y-2">
|
||||
<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" />
|
||||
<span>{formatDate(nextEvent.startDatetime)}</span>
|
||||
<span suppressHydrationWarning>{formatDate(nextEvent.startDatetime)}</span>
|
||||
</div>
|
||||
<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" />
|
||||
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
||||
<span suppressHydrationWarning>{fmtTime(nextEvent.startDatetime)}</span>
|
||||
</div>
|
||||
<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" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { UserPayment } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
|
||||
interface PaymentsTabProps {
|
||||
payments: UserPayment[];
|
||||
@@ -21,7 +22,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
||||
});
|
||||
|
||||
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',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { dashboardApi, UserProfile } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ProfileTabProps {
|
||||
@@ -116,7 +117,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{profile?.memberSince
|
||||
? new Date(profile.memberSince).toLocaleDateString(
|
||||
? parseDate(profile.memberSince).toLocaleDateString(
|
||||
language === 'es' ? 'es-ES' : 'en-US',
|
||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function SecurityTab() {
|
||||
@@ -147,7 +148,7 @@ export default function SecurityTab() {
|
||||
};
|
||||
|
||||
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',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { UserTicket } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
|
||||
interface TicketsTabProps {
|
||||
tickets: UserTicket[];
|
||||
@@ -26,7 +27,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||
});
|
||||
|
||||
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',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -23,6 +23,8 @@ interface EventDetailClientProps {
|
||||
initialEvent: Event;
|
||||
}
|
||||
|
||||
const MAX_TICKETS_PER_PERSON = 5;
|
||||
|
||||
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
||||
const { t, locale } = useLanguage();
|
||||
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
|
||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||
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 = () => {
|
||||
setTicketQuantity(prev => Math.max(1, prev - 1));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { notFound, permanentRedirect } from 'next/navigation';
|
||||
import EventDetailClient from './EventDetailClient';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
@@ -7,6 +7,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description: string;
|
||||
@@ -68,7 +69,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }],
|
||||
},
|
||||
twitter: {
|
||||
@@ -78,7 +79,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
|
||||
images: [imageUrl],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/events/${event.id}`,
|
||||
canonical: `${siteUrl}/events/${event.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -119,11 +120,11 @@ function generateEventJsonLd(event: Event) {
|
||||
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
validFrom: new Date().toISOString(),
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,6 +135,11 @@ export default async function EventDetailPage({ params }: { params: { id: string
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Redirect legacy UUID/alias URLs to the canonical slug (HTTP 308 permanent)
|
||||
if (event.slug && params.id !== event.slug) {
|
||||
permanentRedirect(`/events/${event.slug}`);
|
||||
}
|
||||
|
||||
const jsonLd = generateEventJsonLd(event);
|
||||
|
||||
return (
|
||||
@@ -142,7 +148,7 @@ export default async function EventDetailPage({ params }: { params: { id: string
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<EventDetailClient eventId={params.id} initialEvent={event} />
|
||||
<EventDetailClient eventId={event.slug} initialEvent={event} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function EventsPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayedEvents.map((event) => (
|
||||
<Link key={event.id} href={`/events/${event.id}`} className="block">
|
||||
<Link key={event.id} href={`/events/${event.slug}`} className="block">
|
||||
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
|
||||
{/* Event banner */}
|
||||
{event.bannerUrl ? (
|
||||
|
||||
@@ -68,6 +68,8 @@ export default async function LegalPage({ params, searchParams }: PageProps) {
|
||||
|
||||
return (
|
||||
<LegalPageLayout
|
||||
slug={resolvedParams.slug}
|
||||
initialLocale={locale}
|
||||
title={legalPage.title}
|
||||
content={legalPage.content}
|
||||
lastUpdated={legalPage.lastUpdated}
|
||||
|
||||
@@ -3,6 +3,7 @@ import HeroSection from './components/HeroSection';
|
||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||
import AboutSection from './components/AboutSection';
|
||||
import MediaCarouselSection from './components/MediaCarouselSection';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import NewsletterSection from './components/NewsletterSection';
|
||||
import HomepageFaqSection from './components/HomepageFaqSection';
|
||||
import { getCarouselImages } from '@/lib/carouselImages';
|
||||
@@ -12,6 +13,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface NextEvent {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description: string;
|
||||
@@ -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',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -138,10 +140,10 @@ function generateNextEventJsonLd(event: NextEvent) {
|
||||
(event.availableSeats ?? 0) > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
@@ -116,7 +117,7 @@ export default function AdminBookingsPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
|
||||
@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
|
||||
export default function AdminContactsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
@@ -44,7 +45,7 @@ export default function AdminContactsPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
XMarkIcon,
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -52,6 +55,11 @@ export default function AdminEmailsPage() {
|
||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||
const [logsOffset, setLogsOffset] = 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);
|
||||
|
||||
// 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(() => {
|
||||
if (activeTab === 'logs') {
|
||||
loadLogs();
|
||||
}
|
||||
}, [activeTab, logsOffset]);
|
||||
}, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -233,7 +250,13 @@ export default function AdminEmailsPage() {
|
||||
|
||||
const loadLogs = async () => {
|
||||
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);
|
||||
setLogsTotal(res.pagination.total);
|
||||
} 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 = () => {
|
||||
setTemplateForm({
|
||||
name: '',
|
||||
@@ -363,7 +407,7 @@ export default function AdminEmailsPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -544,7 +588,7 @@ export default function AdminEmailsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
{hasDraft && (
|
||||
<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>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||
@@ -570,7 +614,7 @@ export default function AdminEmailsPage() {
|
||||
<option value="">Choose an event</option>
|
||||
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
@@ -699,6 +743,70 @@ export default function AdminEmailsPage() {
|
||||
{/* Logs Tab */}
|
||||
{activeTab === 'logs' && (
|
||||
<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 */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -714,12 +822,17 @@ export default function AdminEmailsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{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) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<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 className="px-4 py-3">
|
||||
<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 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
|
||||
<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">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -758,7 +879,7 @@ export default function AdminEmailsPage() {
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{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) => (
|
||||
<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="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} <{log.recipientEmail}></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>
|
||||
<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>
|
||||
</Card>
|
||||
))
|
||||
@@ -938,13 +1070,27 @@ export default function AdminEmailsPage() {
|
||||
{selectedLog.errorMessage && (
|
||||
<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 className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleResend(selectedLog)}
|
||||
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 flex-shrink-0">
|
||||
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 className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
||||
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||
<p><strong>Subject:</strong> {selectedLog.subject}</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
ArrowDownTrayIcon,
|
||||
ChevronDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -88,6 +90,14 @@ export default function AdminEventDetailPage() {
|
||||
phone: '',
|
||||
adminNote: '',
|
||||
});
|
||||
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
|
||||
const [inviteGuestForm, setInviteGuestForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
adminNote: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
||||
@@ -212,32 +222,9 @@ export default function AdminEventDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateShort = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
if (currency === 'PYG') {
|
||||
@@ -376,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') => {
|
||||
if (!event) return;
|
||||
setExporting(true);
|
||||
@@ -466,7 +477,7 @@ export default function AdminEventDetailPage() {
|
||||
ticketId: 'TKT-PREVIEW',
|
||||
eventTitle: event?.title || '',
|
||||
eventDate: event ? formatDate(event.startDatetime) : '',
|
||||
eventTime: event ? formatTime(event.startDatetime) : '',
|
||||
eventTime: event ? fmtTime(event.startDatetime) : '',
|
||||
eventLocation: event?.location || '',
|
||||
eventLocationUrl: event?.locationUrl || '',
|
||||
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
||||
@@ -538,7 +549,9 @@ export default function AdminEventDetailPage() {
|
||||
const pendingCount = getTicketsByStatus('pending').length;
|
||||
const checkedInCount = getTicketsByStatus('checked_in').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 }[] = [
|
||||
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
|
||||
@@ -573,7 +586,7 @@ export default function AdminEventDetailPage() {
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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)} · {formatTime(event.startDatetime)}</p>
|
||||
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}</p>
|
||||
</div>
|
||||
{/* Desktop header actions */}
|
||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||||
@@ -581,7 +594,7 @@ export default function AdminEventDetailPage() {
|
||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
|
||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
<Link href={`/events/${event.id}`} target="_blank">
|
||||
<Link href={`/events/${event.slug}`} target="_blank">
|
||||
<Button variant="outline" size="sm">
|
||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||
View Public
|
||||
@@ -605,7 +618,7 @@ export default function AdminEventDetailPage() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||||
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||||
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
||||
@@ -623,7 +636,7 @@ export default function AdminEventDetailPage() {
|
||||
<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">
|
||||
<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 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" />
|
||||
@@ -778,7 +791,7 @@ export default function AdminEventDetailPage() {
|
||||
<div>
|
||||
<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">{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 className="flex items-start gap-3">
|
||||
@@ -909,6 +922,9 @@ export default function AdminEventDetailPage() {
|
||||
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
@@ -1006,15 +1022,20 @@ export default function AdminEventDetailPage() {
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
<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 className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
@@ -1066,14 +1087,17 @@ export default function AdminEventDetailPage() {
|
||||
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
|
||||
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</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)}
|
||||
{!!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 className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||
{ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`}
|
||||
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
||||
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{primary && (
|
||||
@@ -1230,8 +1254,8 @@ export default function AdminEventDetailPage() {
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{ticket.checkinAt ? (
|
||||
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
||||
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
|
||||
})
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
@@ -1293,7 +1317,7 @@ export default function AdminEventDetailPage() {
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{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'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -1837,6 +1861,16 @@ export default function AdminEventDetailPage() {
|
||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
||||
</div>
|
||||
</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>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -2046,6 +2080,86 @@ export default function AdminEventDetailPage() {
|
||||
</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 */}
|
||||
{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">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateI
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const router = useRouter();
|
||||
@@ -27,9 +28,11 @@ export default function AdminEventsPage() {
|
||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||
|
||||
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
titleEs: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
descriptionEs: string;
|
||||
shortDescription: string;
|
||||
@@ -48,6 +51,7 @@ export default function AdminEventsPage() {
|
||||
}>({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
@@ -112,8 +116,9 @@ export default function AdminEventsPage() {
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSlugAliases([]);
|
||||
setFormData({
|
||||
title: '', titleEs: '', description: '', descriptionEs: '',
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
|
||||
@@ -123,18 +128,45 @@ export default function AdminEventsPage() {
|
||||
};
|
||||
|
||||
const isoToLocalDatetime = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
const date = parseDate(isoString);
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
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) => {
|
||||
setFormData({
|
||||
title: event.title, titleEs: event.titleEs || '',
|
||||
title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
|
||||
description: event.description, descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||
@@ -147,6 +179,7 @@ export default function AdminEventsPage() {
|
||||
});
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
loadSlugAliases(event.id);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -163,12 +196,12 @@ export default function AdminEventsPage() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const eventData = {
|
||||
const eventData: Partial<Event> = {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||
startDatetime: formData.startDatetime,
|
||||
endDatetime: formData.endDatetime || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
@@ -176,6 +209,8 @@ export default function AdminEventsPage() {
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
};
|
||||
if (editingEvent) {
|
||||
// Only send slug when editing so creates still auto-generate from title
|
||||
eventData.slug = formData.slug || undefined;
|
||||
await eventsApi.update(editingEvent.id, eventData);
|
||||
toast.success('Event updated');
|
||||
} else {
|
||||
@@ -214,7 +249,7 @@ export default function AdminEventsPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
@@ -292,6 +327,38 @@ export default function AdminEventsPage() {
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{editingEvent && (
|
||||
<div>
|
||||
<Input label="URL Slug" value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="auto-generated from title" />
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
|
||||
. Changing the slug keeps the old one as a redirecting alias.
|
||||
</p>
|
||||
{slugAliases.length > 0 && (
|
||||
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
|
||||
<p className="text-sm font-medium mb-2">URL aliases</p>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Old URLs that still redirect to the current slug. Removing one breaks those links.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{slugAliases.map((alias) => (
|
||||
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-mono truncate">/events/{alias.slug}</span>
|
||||
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
|
||||
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
|
||||
title="Remove alias">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||
<textarea value={formData.description}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { mediaApi, Media } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -108,7 +109,7 @@ export default function AdminGalleryPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { legalPagesApi, LegalPage } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -158,7 +159,7 @@ export default function AdminLegalPagesPage() {
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UserGroupIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
@@ -30,7 +31,7 @@ export default function AdminDashboardPage() {
|
||||
}, []);
|
||||
|
||||
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',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -203,7 +204,7 @@ export default function AdminPaymentsPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
@@ -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
|
||||
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
||||
if (!payment.ticket?.bookingId) {
|
||||
@@ -287,7 +304,7 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
|
||||
// Count all pending payments with the same bookingId
|
||||
const bookingPayments = pendingApprovalPayments.filter(
|
||||
const bookingPayments = visiblePendingApprovalPayments.filter(
|
||||
p => p.ticket?.bookingId === payment.ticket?.bookingId
|
||||
);
|
||||
|
||||
@@ -325,7 +342,7 @@ export default function AdminPaymentsPage() {
|
||||
const paidBookingsCount = getUniqueBookingsCount(
|
||||
payments.filter(p => p.status === 'paid')
|
||||
);
|
||||
const pendingApprovalBookingsCount = getUniqueBookingsCount(pendingApprovalPayments);
|
||||
const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -619,8 +636,8 @@ export default function AdminPaymentsPage() {
|
||||
<div>
|
||||
<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>
|
||||
{pendingApprovalPayments.length !== pendingApprovalBookingsCount && (
|
||||
<p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p>
|
||||
{visiblePendingApprovalPayments.length !== pendingApprovalBookingsCount && (
|
||||
<p className="text-xs text-gray-400">({visiblePendingApprovalPayments.length} tickets)</p>
|
||||
)}
|
||||
</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]',
|
||||
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||||
{locale === 'es' ? 'Pendientes' : 'Pending Approval'}
|
||||
{pendingApprovalPayments.length > 0 && (
|
||||
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span>
|
||||
{visiblePendingApprovalPayments.length > 0 && (
|
||||
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{visiblePendingApprovalPayments.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('all')}
|
||||
@@ -684,7 +701,7 @@ export default function AdminPaymentsPage() {
|
||||
{/* Pending Approval Tab */}
|
||||
{activeTab === 'pending_approval' && (
|
||||
<>
|
||||
{pendingApprovalPayments.length === 0 ? (
|
||||
{visiblePendingApprovalPayments.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">
|
||||
@@ -695,7 +712,7 @@ export default function AdminPaymentsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingApprovalPayments.map((payment) => {
|
||||
{visiblePendingApprovalPayments.map((payment) => {
|
||||
const bookingInfo = getPendingBookingInfo(payment);
|
||||
return (
|
||||
<Card key={payment.id} className="p-4">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
VideoCameraIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
@@ -324,7 +325,7 @@ function InvalidTicketScreen({
|
||||
|
||||
const reasonDetail: Record<InvalidReason, string> = {
|
||||
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',
|
||||
cancelled: 'This ticket has been cancelled and is no longer valid.',
|
||||
not_found: error || 'No ticket matching this code was found.',
|
||||
@@ -765,7 +766,7 @@ export default function AdminScannerPage() {
|
||||
setRecentCheckins((prev) => [
|
||||
{
|
||||
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,
|
||||
},
|
||||
...prev.slice(0, 19),
|
||||
@@ -796,7 +797,7 @@ export default function AdminScannerPage() {
|
||||
setRecentCheckins((prev) => [
|
||||
{
|
||||
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,
|
||||
},
|
||||
...prev.slice(0, 19),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -319,7 +320,7 @@ export default function AdminSettingsPage() {
|
||||
<div>
|
||||
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
|
||||
<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',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -107,7 +108,7 @@ export default function AdminTicketsPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usersApi, User } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -104,7 +105,7 @@ export default function AdminUsersPage() {
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -127,11 +127,11 @@
|
||||
}
|
||||
|
||||
.ProseMirror ul {
|
||||
@apply list-disc list-inside my-3;
|
||||
@apply list-disc list-outside pl-6 my-3;
|
||||
}
|
||||
|
||||
.ProseMirror ol {
|
||||
@apply list-decimal list-inside my-3;
|
||||
@apply list-decimal list-outside pl-6 my-3;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function LinktreePage() {
|
||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
</div>
|
||||
) : nextEvent ? (
|
||||
<Link href={`/events/${nextEvent.id}`} className="block group">
|
||||
<Link href={`/events/${nextEvent.slug}`} className="block group">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
|
||||
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
|
||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -10,6 +11,7 @@ interface LlmsFaq {
|
||||
|
||||
interface LlmsEvent {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
shortDescription?: string;
|
||||
@@ -56,7 +58,7 @@ async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
||||
const EVENT_TIMEZONE = 'America/Asuncion';
|
||||
|
||||
function formatEventDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -66,7 +68,7 @@ function formatEventDate(dateStr: string): string {
|
||||
}
|
||||
|
||||
function formatEventTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||
return parseDate(dateStr).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
@@ -80,7 +82,7 @@ function formatPrice(price: number, currency: string): string {
|
||||
}
|
||||
|
||||
function formatISODate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-CA', {
|
||||
return parseDate(dateStr).toLocaleDateString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -89,7 +91,7 @@ function formatISODate(dateStr: string): string {
|
||||
}
|
||||
|
||||
function formatISOTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleTimeString('en-GB', {
|
||||
return parseDate(dateStr).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
@@ -192,7 +194,7 @@ export async function GET() {
|
||||
if (nextEvent.availableSeats !== undefined) {
|
||||
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
|
||||
}
|
||||
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
|
||||
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.slug}`);
|
||||
if (nextEvent.shortDescription) {
|
||||
lines.push(`- Description: ${nextEvent.shortDescription}`);
|
||||
}
|
||||
@@ -225,7 +227,7 @@ export async function GET() {
|
||||
if (event.availableSeats !== undefined) {
|
||||
lines.push(`- Capacity Remaining: ${event.availableSeats}`);
|
||||
}
|
||||
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`);
|
||||
lines.push(`- Tickets URL: ${siteUrl}/events/${event.slug}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface SitemapEvent {
|
||||
id: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
startDatetime: string;
|
||||
updatedAt: string;
|
||||
@@ -100,7 +101,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => {
|
||||
const isUpcoming = new Date(event.startDatetime) > now;
|
||||
return {
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
lastModified: new Date(event.updatedAt),
|
||||
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
|
||||
priority: isUpcoming ? 0.8 : 0.5,
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function Footer() {
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-10 w-auto"
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
</Link>
|
||||
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}>
|
||||
@@ -107,7 +108,7 @@ export default function Footer() {
|
||||
{legalLinks.map((link) => (
|
||||
<Link
|
||||
key={link.slug}
|
||||
href={`/legal/${link.slug}`}
|
||||
href={`/legal/${link.slug}${locale === 'es' ? '?locale=es' : ''}`}
|
||||
className="hover:opacity-70 transition-colors text-sm"
|
||||
style={{ color: '#002F44' }}
|
||||
>
|
||||
|
||||
@@ -124,6 +124,7 @@ export default function Header() {
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-10 w-auto"
|
||||
style={{ width: 'auto' }}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
@@ -219,6 +220,7 @@ export default function Header() {
|
||||
width={100}
|
||||
height={28}
|
||||
className="h-7 w-auto"
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
<button
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
|
||||
@@ -1,17 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { legalPagesApi } from '@/lib/api';
|
||||
|
||||
interface LegalPageLayoutProps {
|
||||
slug: string;
|
||||
initialLocale: 'en' | 'es';
|
||||
title: string;
|
||||
content: 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 (
|
||||
<div className="section-padding">
|
||||
<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"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Home
|
||||
{t('legalPage.backToHome')}
|
||||
</Link>
|
||||
|
||||
{/* Title */}
|
||||
@@ -31,7 +88,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
|
||||
</h1>
|
||||
{lastUpdated && lastUpdated !== '[Insert Date]' && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Last updated: {lastUpdated}
|
||||
{t('legalPage.lastUpdated', { date: lastUpdated })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -70,12 +127,12 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
|
||||
),
|
||||
// Style lists
|
||||
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}
|
||||
</ul>
|
||||
),
|
||||
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}
|
||||
</ol>
|
||||
),
|
||||
@@ -182,7 +239,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
|
||||
>
|
||||
Back to top
|
||||
{t('legalPage.backToTop')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,11 @@
|
||||
"rucOptional": "Optional - for invoice",
|
||||
"reserveSpot": "Reserve My Spot",
|
||||
"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.",
|
||||
"errors": {
|
||||
"nameRequired": "Please enter your full name",
|
||||
@@ -129,7 +133,8 @@
|
||||
"phoneRequired": "Phone number is required",
|
||||
"bookingFailed": "Booking failed. Please try again.",
|
||||
"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": {
|
||||
@@ -317,6 +322,11 @@
|
||||
"refund": "Refund Policy"
|
||||
}
|
||||
},
|
||||
"legalPage": {
|
||||
"backToHome": "Back to Home",
|
||||
"lastUpdated": "Last updated: {date}",
|
||||
"backToTop": "Back to top"
|
||||
},
|
||||
"linktree": {
|
||||
"tagline": "Language Exchange Community",
|
||||
"nextEvent": "Next Event",
|
||||
|
||||
@@ -117,7 +117,11 @@
|
||||
"rucOptional": "Opcional - para facturación",
|
||||
"reserveSpot": "Reservar Mi Lugar",
|
||||
"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.",
|
||||
"errors": {
|
||||
"nameRequired": "Por favor ingresa tu nombre completo",
|
||||
@@ -129,7 +133,8 @@
|
||||
"phoneRequired": "El número de teléfono es requerido",
|
||||
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
|
||||
"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": {
|
||||
@@ -317,6 +322,11 @@
|
||||
"refund": "Política de Reembolso"
|
||||
}
|
||||
},
|
||||
"legalPage": {
|
||||
"backToHome": "Volver al inicio",
|
||||
"lastUpdated": "Última actualización: {date}",
|
||||
"backToTop": "Volver arriba"
|
||||
},
|
||||
"linktree": {
|
||||
"tagline": "Comunidad de Intercambio de Idiomas",
|
||||
"nextEvent": "Próximo Evento",
|
||||
|
||||
@@ -67,6 +67,12 @@ export const eventsApi = {
|
||||
|
||||
duplicate: (id: string) =>
|
||||
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
|
||||
|
||||
getSlugAliases: (id: string) =>
|
||||
fetchApi<{ aliases: { slug: string; createdAt: string }[] }>(`/api/events/${id}/slug-aliases`),
|
||||
|
||||
deleteSlugAlias: (id: string, slug: string) =>
|
||||
fetchApi<{ message: string }>(`/api/events/${id}/slug-aliases/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Tickets API
|
||||
@@ -180,6 +186,20 @@ export const ticketsApi = {
|
||||
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) =>
|
||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||
`/api/lnbits/status/${ticketId}`
|
||||
@@ -482,10 +502,11 @@ export const emailsApi = {
|
||||
}),
|
||||
|
||||
// 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();
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
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?.offset) query.set('offset', params.offset.toString());
|
||||
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
|
||||
@@ -493,6 +514,11 @@ export const emailsApi = {
|
||||
|
||||
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) => {
|
||||
const query = eventId ? `?eventId=${eventId}` : '';
|
||||
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
||||
@@ -505,6 +531,7 @@ export const emailsApi = {
|
||||
// Types
|
||||
export interface Event {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description: string;
|
||||
@@ -545,6 +572,7 @@ export interface Ticket {
|
||||
checkedInByAdminId?: string;
|
||||
qrCode: string;
|
||||
adminNote?: string;
|
||||
isGuest?: boolean;
|
||||
createdAt: string;
|
||||
event?: Event;
|
||||
payment?: Payment;
|
||||
@@ -792,6 +820,8 @@ export interface EmailLog {
|
||||
sentAt?: string;
|
||||
sentBy?: string;
|
||||
createdAt: string;
|
||||
resendAttempts?: number;
|
||||
lastResentAt?: string;
|
||||
}
|
||||
|
||||
export interface EmailStats {
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
// 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
|
||||
// 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';
|
||||
|
||||
@@ -14,11 +19,29 @@ function pickLocale(locale: Locale): string {
|
||||
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"
|
||||
*/
|
||||
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
|
||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
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"
|
||||
*/
|
||||
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
|
||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
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)
|
||||
*/
|
||||
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
|
||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -55,7 +78,7 @@ export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string
|
||||
* "Feb 14, 2026" / "14 feb 2026"
|
||||
*/
|
||||
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
|
||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -67,7 +90,7 @@ export function formatDateCompact(dateStr: string, locale: Locale = 'en'): strin
|
||||
* "04:30 PM" / "16:30"
|
||||
*/
|
||||
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',
|
||||
minute: '2-digit',
|
||||
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
|
||||
*/
|
||||
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
|
||||
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
||||
return parseDate(dateStr).toLocaleString(pickLocale(locale), {
|
||||
month: 'short',
|
||||
day: '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
|
||||
*/
|
||||
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
|
||||
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
||||
return parseDate(dateStr).toLocaleString(pickLocale(locale), {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
Reference in New Issue
Block a user