2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,6 +37,8 @@ backend/uploads/
|
|||||||
# Tooling
|
# Tooling
|
||||||
.turbo/
|
.turbo/
|
||||||
.cursor/
|
.cursor/
|
||||||
|
.agents/
|
||||||
|
skills-lock.json
|
||||||
.npm-cache/
|
.npm-cache/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Migration: Add is_guest column to tickets
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||||
@@ -533,8 +538,8 @@ async function migrate() {
|
|||||||
description_es TEXT,
|
description_es TEXT,
|
||||||
short_description VARCHAR(300),
|
short_description VARCHAR(300),
|
||||||
short_description_es VARCHAR(300),
|
short_description_es VARCHAR(300),
|
||||||
start_datetime TIMESTAMP NOT NULL,
|
start_datetime TIMESTAMPTZ NOT NULL,
|
||||||
end_datetime TIMESTAMP,
|
end_datetime TIMESTAMPTZ,
|
||||||
location VARCHAR(500) NOT NULL,
|
location VARCHAR(500) NOT NULL,
|
||||||
location_url VARCHAR(500),
|
location_url VARCHAR(500),
|
||||||
price DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
price DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||||
@@ -565,6 +570,15 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Migrate event datetime columns from TIMESTAMP to TIMESTAMPTZ for
|
||||||
|
// unambiguous UTC storage (eliminates pg driver timezone interpretation).
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN start_datetime TYPE TIMESTAMPTZ USING start_datetime AT TIME ZONE 'UTC'`);
|
||||||
|
} catch (e) { /* already timestamptz or other issue */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN end_datetime TYPE TIMESTAMPTZ USING end_datetime AT TIME ZONE 'UTC'`);
|
||||||
|
} catch (e) { /* already timestamptz or other issue */ }
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS tickets (
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -599,6 +613,11 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`);
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Migration: Add is_guest column to tickets
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
|||||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||||
qrCode: text('qr_code'),
|
qrCode: text('qr_code'),
|
||||||
adminNote: text('admin_note'),
|
adminNote: text('admin_note'),
|
||||||
|
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,8 +393,8 @@ export const pgEvents = pgTable('events', {
|
|||||||
descriptionEs: pgText('description_es'),
|
descriptionEs: pgText('description_es'),
|
||||||
shortDescription: varchar('short_description', { length: 300 }),
|
shortDescription: varchar('short_description', { length: 300 }),
|
||||||
shortDescriptionEs: varchar('short_description_es', { length: 300 }),
|
shortDescriptionEs: varchar('short_description_es', { length: 300 }),
|
||||||
startDatetime: timestamp('start_datetime').notNull(),
|
startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(),
|
||||||
endDatetime: timestamp('end_datetime'),
|
endDatetime: timestamp('end_datetime', { withTimezone: true }),
|
||||||
location: varchar('location', { length: 500 }).notNull(),
|
location: varchar('location', { length: 500 }).notNull(),
|
||||||
locationUrl: varchar('location_url', { length: 500 }),
|
locationUrl: varchar('location_url', { length: 500 }),
|
||||||
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||||
@@ -423,6 +424,7 @@ export const pgTickets = pgTable('tickets', {
|
|||||||
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
|
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
|
||||||
qrCode: varchar('qr_code', { length: 255 }),
|
qrCode: varchar('qr_code', { length: 255 }),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
|
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,49 @@ export function toDbDate(date: Date | string): string | Date {
|
|||||||
return getDbType() === 'postgres' ? d : d.toISOString();
|
return getDbType() === 'postgres' ? d : d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NAIVE_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a datetime string that represents wall-clock time in a given timezone
|
||||||
|
* and return the corresponding UTC Date.
|
||||||
|
*
|
||||||
|
* Naive strings (no "Z" or offset) are interpreted as wall-clock time in
|
||||||
|
* `timezone`. Strings that already carry a timezone indicator are parsed
|
||||||
|
* directly so existing UTC ISO values still work.
|
||||||
|
*/
|
||||||
|
export function parseEventDatetime(
|
||||||
|
datetime: string,
|
||||||
|
timezone: string = 'America/Asuncion',
|
||||||
|
): Date {
|
||||||
|
if (!NAIVE_DATETIME_RE.test(datetime)) {
|
||||||
|
return new Date(datetime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat the digits as UTC so we have a stable reference instant.
|
||||||
|
const fakeUTC = new Date(datetime + 'Z');
|
||||||
|
|
||||||
|
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
|
||||||
|
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||||
|
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
|
||||||
|
|
||||||
|
// The gap between the two tells us the tz offset at this point in time.
|
||||||
|
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
|
||||||
|
|
||||||
|
return new Date(fakeUTC.getTime() + offsetMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a datetime string to the appropriate DB format, interpreting naive
|
||||||
|
* strings as wall-clock time in `timezone` (defaults to America/Asuncion).
|
||||||
|
*/
|
||||||
|
export function toDbDateTz(
|
||||||
|
datetime: string,
|
||||||
|
timezone: string = 'America/Asuncion',
|
||||||
|
): string | Date {
|
||||||
|
const d = parseEventDatetime(datetime, timezone);
|
||||||
|
return getDbType() === 'postgres' ? d : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a boolean value to the appropriate format for the database type.
|
* Convert a boolean value to the appropriate format for the database type.
|
||||||
* - SQLite: returns boolean (true/false) for mode: 'boolean'
|
* - SQLite: returns boolean (true/false) for mode: 'boolean'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||||
import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm';
|
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
@@ -129,7 +129,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq((tickets as any).eventId, event.id),
|
eq((tickets as any).eventId, event.id),
|
||||||
eq((tickets as any).status, 'confirmed')
|
eq((tickets as any).status, 'confirmed'),
|
||||||
|
ne((tickets as any).isGuest, 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -141,7 +142,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq((tickets as any).eventId, event.id),
|
eq((tickets as any).eventId, event.id),
|
||||||
eq((tickets as any).status, 'checked_in')
|
eq((tickets as any).status, 'checked_in'),
|
||||||
|
ne((tickets as any).isGuest, 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
|
||||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
@@ -201,6 +201,13 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function getSiteTimezone(): Promise<string> {
|
||||||
|
const settings = await dbGet<any>(
|
||||||
|
(db as any).select().from(siteSettings).limit(1)
|
||||||
|
);
|
||||||
|
return settings?.timezone || 'America/Asuncion';
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to get ticket count for an event
|
// Helper function to get ticket count for an event
|
||||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
async function getEventTicketCount(eventId: string): Promise<number> {
|
||||||
const ticketCount = await dbGet<any>(
|
const ticketCount = await dbGet<any>(
|
||||||
@@ -316,6 +323,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
|
const tz = await getSiteTimezone();
|
||||||
|
|
||||||
// Convert data for database compatibility
|
// Convert data for database compatibility
|
||||||
const dbData = convertBooleansForDb(data);
|
const dbData = convertBooleansForDb(data);
|
||||||
@@ -323,8 +331,8 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
const newEvent = {
|
const newEvent = {
|
||||||
id,
|
id,
|
||||||
...dbData,
|
...dbData,
|
||||||
startDatetime: toDbDate(data.startDatetime),
|
startDatetime: toDbDateTz(data.startDatetime, tz),
|
||||||
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null,
|
endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -351,14 +359,15 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
const tz = await getSiteTimezone();
|
||||||
// Convert data for database compatibility
|
// Convert data for database compatibility
|
||||||
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
||||||
// Convert datetime fields if present
|
// Convert datetime fields if present
|
||||||
if (data.startDatetime) {
|
if (data.startDatetime) {
|
||||||
updateData.startDatetime = toDbDate(data.startDatetime);
|
updateData.startDatetime = toDbDateTz(data.startDatetime, tz);
|
||||||
}
|
}
|
||||||
if (data.endDatetime !== undefined) {
|
if (data.endDatetime !== undefined) {
|
||||||
updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null;
|
updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
|
|||||||
@@ -1394,6 +1394,142 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin invite guest ticket (free, confirmed, not counted in revenue)
|
||||||
|
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||||
|
eventId: z.string(),
|
||||||
|
firstName: z.string().min(1),
|
||||||
|
lastName: z.string().optional().or(z.literal('')),
|
||||||
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
|
phone: z.string().optional().or(z.literal('')),
|
||||||
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
|
adminNote: z.string().max(1000).optional(),
|
||||||
|
})), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||||
|
);
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const adminUser = (c as any).get('user');
|
||||||
|
|
||||||
|
// Find or create user (use placeholder email if none provided)
|
||||||
|
const attendeeEmail = data.email && data.email.trim()
|
||||||
|
? data.email.trim()
|
||||||
|
: `guest-${generateId()}@guestinvite.local`;
|
||||||
|
|
||||||
|
const fullName = data.lastName && data.lastName.trim()
|
||||||
|
? `${data.firstName} ${data.lastName}`.trim()
|
||||||
|
: data.firstName;
|
||||||
|
|
||||||
|
let user = await dbGet<any>(
|
||||||
|
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const userId = generateId();
|
||||||
|
user = {
|
||||||
|
id: userId,
|
||||||
|
email: attendeeEmail,
|
||||||
|
password: '',
|
||||||
|
name: fullName,
|
||||||
|
phone: data.phone || null,
|
||||||
|
role: 'user',
|
||||||
|
languagePreference: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await (db as any).insert(users).values(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing active ticket (only for real emails, not placeholder)
|
||||||
|
if (data.email && data.email.trim()) {
|
||||||
|
const existingTicket = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).userId, user.id),
|
||||||
|
eq((tickets as any).eventId, data.eventId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||||
|
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = generateId();
|
||||||
|
const qrCode = generateTicketCode();
|
||||||
|
|
||||||
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
|
userId: user.id,
|
||||||
|
eventId: data.eventId,
|
||||||
|
attendeeFirstName: data.firstName,
|
||||||
|
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||||
|
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
|
||||||
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
|
status: 'confirmed',
|
||||||
|
isGuest: 1,
|
||||||
|
qrCode,
|
||||||
|
checkinAt: null,
|
||||||
|
adminNote: data.adminNote || null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
|
|
||||||
|
// Create a $0 payment record to track the invite
|
||||||
|
const paymentId = generateId();
|
||||||
|
const newPayment = {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: 'cash',
|
||||||
|
amount: 0,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'paid',
|
||||||
|
reference: 'Guest invite',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: adminUser?.id || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
|
// Send booking confirmation email if a real email was provided
|
||||||
|
if (data.email && data.email.trim()) {
|
||||||
|
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...newTicket,
|
||||||
|
event: {
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payment: newPayment,
|
||||||
|
message: 'Guest ticket created successfully',
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
// Get all tickets (admin) - includes payment for each ticket
|
// Get all tickets (admin) - includes payment for each ticket
|
||||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
|
|||||||
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>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
|
||||||
|
|
||||||
|
// Terms & Privacy agreement (not persisted across page loads)
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
const [termsError, setTermsError] = useState<string | null>(null);
|
||||||
|
|
||||||
const rucPattern = /^\d{6,10}$/;
|
const rucPattern = /^\d{6,10}$/;
|
||||||
|
|
||||||
// Format RUC input: digits only, max 10
|
// Format RUC input: digits only, max 10
|
||||||
@@ -217,6 +221,13 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Clear the terms error as soon as the user agrees
|
||||||
|
useEffect(() => {
|
||||||
|
if (agreedToTerms && termsError) {
|
||||||
|
setTermsError(null);
|
||||||
|
}
|
||||||
|
}, [agreedToTerms, termsError]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
|
|
||||||
@@ -261,7 +272,20 @@ export default function BookingPage() {
|
|||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
setAttendeeErrors(newAttendeeErrors);
|
setAttendeeErrors(newAttendeeErrors);
|
||||||
return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0;
|
|
||||||
|
let termsOk = true;
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
setTermsError(t('booking.form.errors.termsRequired'));
|
||||||
|
termsOk = false;
|
||||||
|
} else {
|
||||||
|
setTermsError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
Object.keys(newErrors).length === 0 &&
|
||||||
|
Object.keys(newAttendeeErrors).length === 0 &&
|
||||||
|
termsOk
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Connect to SSE for real-time payment updates
|
// Connect to SSE for real-time payment updates
|
||||||
@@ -376,6 +400,10 @@ export default function BookingPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
setTermsError(t('booking.form.errors.termsRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!event || !validateForm()) return;
|
if (!event || !validateForm()) return;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -1323,13 +1351,58 @@ export default function BookingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Terms & Privacy agreement */}
|
||||||
|
<Card className="mb-6 p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
id="booking-terms-agree"
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreedToTerms}
|
||||||
|
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||||
|
aria-required="true"
|
||||||
|
aria-invalid={termsError ? true : undefined}
|
||||||
|
aria-describedby={termsError ? 'booking-terms-error' : undefined}
|
||||||
|
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="booking-terms-agree"
|
||||||
|
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
{t('booking.form.termsAgreePart1')}
|
||||||
|
<Link
|
||||||
|
href="/legal/terms-policy"
|
||||||
|
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"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-secondary-blue hover:text-brand-navy underline"
|
||||||
|
>
|
||||||
|
{t('booking.form.privacyPolicy')}
|
||||||
|
</Link>
|
||||||
|
{t('booking.form.termsAgreePart3')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{termsError && (
|
||||||
|
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
|
||||||
|
{termsError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isLoading={submitting}
|
isLoading={submitting}
|
||||||
disabled={paymentMethods.length === 0}
|
disabled={paymentMethods.length === 0 || !agreedToTerms}
|
||||||
>
|
>
|
||||||
{formData.paymentMethod === 'cash'
|
{formData.paymentMethod === 'cash'
|
||||||
? t('booking.form.reserveSpot')
|
? t('booking.form.reserveSpot')
|
||||||
@@ -1338,10 +1411,6 @@ export default function BookingPage() {
|
|||||||
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
|
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
|
||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-4">
|
|
||||||
{t('booking.form.termsNote')}
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
<div className="mt-4 md:mt-5 space-y-2">
|
<div className="mt-4 md:mt-5 space-y-2">
|
||||||
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{formatDate(nextEvent.startDatetime)}</span>
|
<span suppressHydrationWarning>{formatDate(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
<span suppressHydrationWarning>{fmtTime(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { UserPayment } from '@/lib/api';
|
import { UserPayment } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
|
|
||||||
interface PaymentsTabProps {
|
interface PaymentsTabProps {
|
||||||
payments: UserPayment[];
|
payments: UserPayment[];
|
||||||
@@ -21,7 +22,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { dashboardApi, UserProfile } from '@/lib/api';
|
import { dashboardApi, UserProfile } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface ProfileTabProps {
|
interface ProfileTabProps {
|
||||||
@@ -116,7 +117,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{profile?.memberSince
|
{profile?.memberSince
|
||||||
? new Date(profile.memberSince).toLocaleDateString(
|
? parseDate(profile.memberSince).toLocaleDateString(
|
||||||
language === 'es' ? 'es-ES' : 'en-US',
|
language === 'es' ? 'es-ES' : 'en-US',
|
||||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
|
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function SecurityTab() {
|
export default function SecurityTab() {
|
||||||
@@ -147,7 +148,7 @@ export default function SecurityTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { UserTicket } from '@/lib/api';
|
import { UserTicket } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
|
|
||||||
interface TicketsTabProps {
|
interface TicketsTabProps {
|
||||||
tickets: UserTicket[];
|
tickets: UserTicket[];
|
||||||
@@ -26,7 +27,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import HeroSection from './components/HeroSection';
|
|||||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||||
import AboutSection from './components/AboutSection';
|
import AboutSection from './components/AboutSection';
|
||||||
import MediaCarouselSection from './components/MediaCarouselSection';
|
import MediaCarouselSection from './components/MediaCarouselSection';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import NewsletterSection from './components/NewsletterSection';
|
import NewsletterSection from './components/NewsletterSection';
|
||||||
import HomepageFaqSection from './components/HomepageFaqSection';
|
import HomepageFaqSection from './components/HomepageFaqSection';
|
||||||
import { getCarouselImages } from '@/lib/carouselImages';
|
import { getCarouselImages } from '@/lib/carouselImages';
|
||||||
@@ -63,7 +64,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
|
const eventDate = parseDate(event.startDatetime).toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
@@ -116,7 +117,7 @@ export default function AdminBookingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
|
|
||||||
export default function AdminContactsPage() {
|
export default function AdminContactsPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
@@ -44,7 +45,7 @@ export default function AdminContactsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
@@ -391,7 +392,7 @@ export default function AdminEmailsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -572,7 +573,7 @@ export default function AdminEmailsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasDraft && (
|
{hasDraft && (
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
|
Draft saved {composeForm.savedAt ? parseDate(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||||
@@ -598,7 +599,7 @@ export default function AdminEmailsPage() {
|
|||||||
<option value="">Choose an event</option>
|
<option value="">Choose an event</option>
|
||||||
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
||||||
<option key={event.id} value={event.id}>
|
<option key={event.id} value={event.id}>
|
||||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
{event.title} - {parseDate(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
|
||||||
|
import { formatDateLong, formatDateCompact, formatTime, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
EllipsisVerticalIcon,
|
EllipsisVerticalIcon,
|
||||||
|
StarIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -88,6 +90,14 @@ export default function AdminEventDetailPage() {
|
|||||||
phone: '',
|
phone: '',
|
||||||
adminNote: '',
|
adminNote: '',
|
||||||
});
|
});
|
||||||
|
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
|
||||||
|
const [inviteGuestForm, setInviteGuestForm] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
adminNote: '',
|
||||||
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
||||||
@@ -212,32 +222,9 @@ export default function AdminEventDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
timeZone: 'America/Asuncion',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateShort = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: 'America/Asuncion',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
timeZone: 'America/Asuncion',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number, currency: string) => {
|
const formatCurrency = (amount: number, currency: string) => {
|
||||||
if (currency === 'PYG') {
|
if (currency === 'PYG') {
|
||||||
@@ -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') => {
|
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
@@ -466,7 +477,7 @@ export default function AdminEventDetailPage() {
|
|||||||
ticketId: 'TKT-PREVIEW',
|
ticketId: 'TKT-PREVIEW',
|
||||||
eventTitle: event?.title || '',
|
eventTitle: event?.title || '',
|
||||||
eventDate: event ? formatDate(event.startDatetime) : '',
|
eventDate: event ? formatDate(event.startDatetime) : '',
|
||||||
eventTime: event ? formatTime(event.startDatetime) : '',
|
eventTime: event ? fmtTime(event.startDatetime) : '',
|
||||||
eventLocation: event?.location || '',
|
eventLocation: event?.location || '',
|
||||||
eventLocationUrl: event?.locationUrl || '',
|
eventLocationUrl: event?.locationUrl || '',
|
||||||
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
||||||
@@ -538,7 +549,9 @@ export default function AdminEventDetailPage() {
|
|||||||
const pendingCount = getTicketsByStatus('pending').length;
|
const pendingCount = getTicketsByStatus('pending').length;
|
||||||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||||||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||||||
const revenue = (confirmedCount + checkedInCount) * event.price;
|
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
|
||||||
|
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
|
||||||
|
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
|
||||||
|
|
||||||
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
||||||
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
|
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
|
||||||
@@ -573,7 +586,7 @@ export default function AdminEventDetailPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
|
||||||
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {formatTime(event.startDatetime)}</p>
|
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Desktop header actions */}
|
{/* Desktop header actions */}
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||||||
@@ -623,7 +636,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
|
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
<CalendarIcon className="w-3.5 h-3.5" />
|
<CalendarIcon className="w-3.5 h-3.5" />
|
||||||
{formatDateShort(event.startDatetime)} {formatTime(event.startDatetime)}{event.endDatetime && ` – ${formatTime(event.endDatetime)}`}
|
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
<MapPinIcon className="w-3.5 h-3.5" />
|
<MapPinIcon className="w-3.5 h-3.5" />
|
||||||
@@ -778,7 +791,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Date & Time</p>
|
<p className="font-medium text-sm">Date & Time</p>
|
||||||
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
|
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
|
||||||
<p className="text-sm text-gray-600">{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}</p>
|
<p className="text-sm text-gray-600">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -909,6 +922,9 @@ export default function AdminEventDetailPage() {
|
|||||||
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
||||||
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
|
||||||
|
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
||||||
|
</DropdownItem>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
{(searchQuery || statusFilter !== 'all') && (
|
{(searchQuery || statusFilter !== 'all') && (
|
||||||
@@ -1006,15 +1022,20 @@ export default function AdminEventDetailPage() {
|
|||||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{getStatusBadge(ticket.status, true)}
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{getStatusBadge(ticket.status, true)}
|
||||||
|
{!!ticket.isGuest && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{ticket.checkinAt && (
|
{ticket.checkinAt && (
|
||||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
{new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}
|
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||||
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
@@ -1066,14 +1087,17 @@ export default function AdminEventDetailPage() {
|
|||||||
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
|
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
|
||||||
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
|
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||||
{getStatusBadge(ticket.status, true)}
|
{getStatusBadge(ticket.status, true)}
|
||||||
|
{!!ticket.isGuest && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
<p className="text-[10px] text-gray-400">
|
<p className="text-[10px] text-gray-400">
|
||||||
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
||||||
{ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`}
|
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{primary && (
|
{primary && (
|
||||||
@@ -1230,8 +1254,8 @@ export default function AdminEventDetailPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||||
{ticket.checkinAt ? (
|
{ticket.checkinAt ? (
|
||||||
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-300">—</span>
|
<span className="text-gray-300">—</span>
|
||||||
@@ -1293,7 +1317,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
<p className="text-[10px] text-gray-400">
|
<p className="text-[10px] text-gray-400">
|
||||||
{ticket.checkinAt
|
{ticket.checkinAt
|
||||||
? `Checked in ${new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`
|
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
|
||||||
: 'Not checked in'}
|
: 'Not checked in'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -1837,6 +1861,16 @@ export default function AdminEventDetailPage() {
|
|||||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<StarIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Invite Guest</p>
|
||||||
|
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
@@ -2046,6 +2080,86 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invite Guest Modal */}
|
||||||
|
{showInviteGuestModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||||
|
onClick={() => setShowInviteGuestModal(false)}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">Invite Guest</h2>
|
||||||
|
<p className="text-xs text-gray-500">Free ticket — not counted in revenue</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowInviteGuestModal(false)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||||
|
<input type="text" required value={inviteGuestForm.firstName}
|
||||||
|
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="First name" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||||
|
<input type="text" value={inviteGuestForm.lastName}
|
||||||
|
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Last name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Email</label>
|
||||||
|
<input type="email" value={inviteGuestForm.email}
|
||||||
|
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="email@example.com (optional)" />
|
||||||
|
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||||
|
<input type="tel" value={inviteGuestForm.phone}
|
||||||
|
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="+595 981 123456" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||||
|
<textarea value={inviteGuestForm.adminNote}
|
||||||
|
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: e.target.value })}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={2} placeholder="Internal note..." />
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||||
|
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||||
|
<StarIcon className="w-4 h-4 mr-1.5" />
|
||||||
|
Invite Guest
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Note Modal */}
|
{/* Note Modal */}
|
||||||
{showNoteModal && selectedTicket && (
|
{showNoteModal && selectedTicket && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateI
|
|||||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
|
|
||||||
export default function AdminEventsPage() {
|
export default function AdminEventsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -123,13 +124,19 @@ export default function AdminEventsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isoToLocalDatetime = (isoString: string): string => {
|
const isoToLocalDatetime = (isoString: string): string => {
|
||||||
const date = new Date(isoString);
|
const date = parseDate(isoString);
|
||||||
const year = date.getFullYear();
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
timeZone: EVENT_TIMEZONE,
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
year: 'numeric',
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
month: '2-digit',
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
day: '2-digit',
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(date);
|
||||||
|
const get = (type: string) => parts.find(p => p.type === type)!.value;
|
||||||
|
const h = get('hour') === '24' ? '00' : get('hour');
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (event: Event) => {
|
const handleEdit = (event: Event) => {
|
||||||
@@ -167,8 +174,8 @@ export default function AdminEventsPage() {
|
|||||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
startDatetime: formData.startDatetime,
|
||||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
endDatetime: formData.endDatetime || undefined,
|
||||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||||
@@ -214,7 +221,7 @@ export default function AdminEventsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
|
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { mediaApi, Media } from '@/lib/api';
|
import { mediaApi, Media } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import {
|
||||||
@@ -108,7 +109,7 @@ export default function AdminGalleryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { legalPagesApi, LegalPage } from '@/lib/api';
|
import { legalPagesApi, LegalPage } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
@@ -158,7 +159,7 @@ export default function AdminLegalPagesPage() {
|
|||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
@@ -30,7 +31,7 @@ export default function AdminDashboardPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
@@ -203,7 +204,7 @@ export default function AdminPaymentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -280,6 +281,22 @@ export default function AdminPaymentsPage() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hide pending-approval payments whose event has already ended.
|
||||||
|
// Fall back to startDatetime when endDatetime is absent; keep visible when we
|
||||||
|
// can't classify (event missing from list and no startDatetime on payment.event).
|
||||||
|
const visiblePendingApprovalPayments = (() => {
|
||||||
|
const now = new Date();
|
||||||
|
return pendingApprovalPayments.filter((payment) => {
|
||||||
|
const eventId = payment.event?.id;
|
||||||
|
const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined;
|
||||||
|
const endIso = fullEvent?.endDatetime
|
||||||
|
|| fullEvent?.startDatetime
|
||||||
|
|| payment.event?.startDatetime;
|
||||||
|
if (!endIso) return true;
|
||||||
|
return parseDate(endIso).getTime() >= now.getTime();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// Get booking info for pending approval payments
|
// Get booking info for pending approval payments
|
||||||
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
||||||
if (!payment.ticket?.bookingId) {
|
if (!payment.ticket?.bookingId) {
|
||||||
@@ -287,7 +304,7 @@ export default function AdminPaymentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count all pending payments with the same bookingId
|
// Count all pending payments with the same bookingId
|
||||||
const bookingPayments = pendingApprovalPayments.filter(
|
const bookingPayments = visiblePendingApprovalPayments.filter(
|
||||||
p => p.ticket?.bookingId === payment.ticket?.bookingId
|
p => p.ticket?.bookingId === payment.ticket?.bookingId
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -325,7 +342,7 @@ export default function AdminPaymentsPage() {
|
|||||||
const paidBookingsCount = getUniqueBookingsCount(
|
const paidBookingsCount = getUniqueBookingsCount(
|
||||||
payments.filter(p => p.status === 'paid')
|
payments.filter(p => p.status === 'paid')
|
||||||
);
|
);
|
||||||
const pendingApprovalBookingsCount = getUniqueBookingsCount(pendingApprovalPayments);
|
const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -619,8 +636,8 @@ export default function AdminPaymentsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
|
||||||
<p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p>
|
<p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p>
|
||||||
{pendingApprovalPayments.length !== pendingApprovalBookingsCount && (
|
{visiblePendingApprovalPayments.length !== pendingApprovalBookingsCount && (
|
||||||
<p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p>
|
<p className="text-xs text-gray-400">({visiblePendingApprovalPayments.length} tickets)</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -669,8 +686,8 @@ export default function AdminPaymentsPage() {
|
|||||||
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
||||||
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||||||
{locale === 'es' ? 'Pendientes' : 'Pending Approval'}
|
{locale === 'es' ? 'Pendientes' : 'Pending Approval'}
|
||||||
{pendingApprovalPayments.length > 0 && (
|
{visiblePendingApprovalPayments.length > 0 && (
|
||||||
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span>
|
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{visiblePendingApprovalPayments.length}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setActiveTab('all')}
|
<button onClick={() => setActiveTab('all')}
|
||||||
@@ -684,7 +701,7 @@ export default function AdminPaymentsPage() {
|
|||||||
{/* Pending Approval Tab */}
|
{/* Pending Approval Tab */}
|
||||||
{activeTab === 'pending_approval' && (
|
{activeTab === 'pending_approval' && (
|
||||||
<>
|
<>
|
||||||
{pendingApprovalPayments.length === 0 ? (
|
{visiblePendingApprovalPayments.length === 0 ? (
|
||||||
<Card className="p-12 text-center">
|
<Card className="p-12 text-center">
|
||||||
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
|
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
@@ -695,7 +712,7 @@ export default function AdminPaymentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingApprovalPayments.map((payment) => {
|
{visiblePendingApprovalPayments.map((payment) => {
|
||||||
const bookingInfo = getPendingBookingInfo(payment);
|
const bookingInfo = getPendingBookingInfo(payment);
|
||||||
return (
|
return (
|
||||||
<Card key={payment.id} className="p-4">
|
<Card key={payment.id} className="p-4">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
@@ -324,7 +325,7 @@ function InvalidTicketScreen({
|
|||||||
|
|
||||||
const reasonDetail: Record<InvalidReason, string> = {
|
const reasonDetail: Record<InvalidReason, string> = {
|
||||||
already_checked_in: validation?.ticket?.checkinAt
|
already_checked_in: validation?.ticket?.checkinAt
|
||||||
? `Checked in at ${new Date(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
|
? `Checked in at ${parseDate(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
|
||||||
: 'This ticket was already used',
|
: 'This ticket was already used',
|
||||||
cancelled: 'This ticket has been cancelled and is no longer valid.',
|
cancelled: 'This ticket has been cancelled and is no longer valid.',
|
||||||
not_found: error || 'No ticket matching this code was found.',
|
not_found: error || 'No ticket matching this code was found.',
|
||||||
@@ -765,7 +766,7 @@ export default function AdminScannerPage() {
|
|||||||
setRecentCheckins((prev) => [
|
setRecentCheckins((prev) => [
|
||||||
{
|
{
|
||||||
name: result.ticket.attendeeName || 'Guest',
|
name: result.ticket.attendeeName || 'Guest',
|
||||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
|
||||||
ticketId: scanResult.validation!.ticket!.id,
|
ticketId: scanResult.validation!.ticket!.id,
|
||||||
},
|
},
|
||||||
...prev.slice(0, 19),
|
...prev.slice(0, 19),
|
||||||
@@ -796,7 +797,7 @@ export default function AdminScannerPage() {
|
|||||||
setRecentCheckins((prev) => [
|
setRecentCheckins((prev) => [
|
||||||
{
|
{
|
||||||
name: result.ticket.attendeeName || 'Guest',
|
name: result.ticket.attendeeName || 'Guest',
|
||||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
|
||||||
ticketId: searchDetailValidation!.ticket!.id,
|
ticketId: searchDetailValidation!.ticket!.id,
|
||||||
},
|
},
|
||||||
...prev.slice(0, 19),
|
...prev.slice(0, 19),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
|
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
@@ -319,7 +320,7 @@ export default function AdminSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
|
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
|
||||||
<p className="text-sm text-amber-700">
|
<p className="text-sm text-amber-700">
|
||||||
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
{parseDate(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
@@ -107,7 +108,7 @@ export default function AdminTicketsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { usersApi, User } from '@/lib/api';
|
import { usersApi, User } from '@/lib/api';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
@@ -104,7 +105,7 @@ export default function AdminUsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
|
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { parseDate } from '@/lib/utils';
|
||||||
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
@@ -56,7 +57,7 @@ async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
|||||||
const EVENT_TIMEZONE = 'America/Asuncion';
|
const EVENT_TIMEZONE = 'America/Asuncion';
|
||||||
|
|
||||||
function formatEventDate(dateStr: string): string {
|
function formatEventDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
return parseDate(dateStr).toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
@@ -66,7 +67,7 @@ function formatEventDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatEventTime(dateStr: string): string {
|
function formatEventTime(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleTimeString('en-US', {
|
return parseDate(dateStr).toLocaleTimeString('en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true,
|
hour12: true,
|
||||||
@@ -80,7 +81,7 @@ function formatPrice(price: number, currency: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatISODate(dateStr: string): string {
|
function formatISODate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString('en-CA', {
|
return parseDate(dateStr).toLocaleDateString('en-CA', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -89,7 +90,7 @@ function formatISODate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatISOTime(dateStr: string): string {
|
function formatISOTime(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleTimeString('en-GB', {
|
return parseDate(dateStr).toLocaleTimeString('en-GB', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: false,
|
hour12: false,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default function Footer() {
|
|||||||
width={140}
|
width={140}
|
||||||
height={40}
|
height={40}
|
||||||
className="h-10 w-auto"
|
className="h-10 w-auto"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}>
|
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export default function Header() {
|
|||||||
width={140}
|
width={140}
|
||||||
height={40}
|
height={40}
|
||||||
className="h-10 w-auto"
|
className="h-10 w-auto"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -219,6 +220,7 @@ export default function Header() {
|
|||||||
width={100}
|
width={100}
|
||||||
height={28}
|
height={28}
|
||||||
className="h-7 w-auto"
|
className="h-7 w-auto"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
|||||||
@@ -117,7 +117,11 @@
|
|||||||
"rucOptional": "Optional - for invoice",
|
"rucOptional": "Optional - for invoice",
|
||||||
"reserveSpot": "Reserve My Spot",
|
"reserveSpot": "Reserve My Spot",
|
||||||
"proceedPayment": "Proceed to Payment",
|
"proceedPayment": "Proceed to Payment",
|
||||||
"termsNote": "By booking, you agree to our terms and conditions.",
|
"termsAgreePart1": "I agree to the ",
|
||||||
|
"termsOfService": "Terms of Service",
|
||||||
|
"termsAgreePart2": " and ",
|
||||||
|
"privacyPolicy": "Privacy Policy",
|
||||||
|
"termsAgreePart3": ".",
|
||||||
"soldOutMessage": "This event is fully booked. Check back later or browse other events.",
|
"soldOutMessage": "This event is fully booked. Check back later or browse other events.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"nameRequired": "Please enter your full name",
|
"nameRequired": "Please enter your full name",
|
||||||
@@ -129,7 +133,8 @@
|
|||||||
"phoneRequired": "Phone number is required",
|
"phoneRequired": "Phone number is required",
|
||||||
"bookingFailed": "Booking failed. Please try again.",
|
"bookingFailed": "Booking failed. Please try again.",
|
||||||
"rucInvalidFormat": "Invalid format. Example: 12345678-9",
|
"rucInvalidFormat": "Invalid format. Example: 12345678-9",
|
||||||
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number."
|
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number.",
|
||||||
|
"termsRequired": "You must agree to the Terms of Service and Privacy Policy to continue."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
|
|||||||
@@ -117,7 +117,11 @@
|
|||||||
"rucOptional": "Opcional - para facturación",
|
"rucOptional": "Opcional - para facturación",
|
||||||
"reserveSpot": "Reservar Mi Lugar",
|
"reserveSpot": "Reservar Mi Lugar",
|
||||||
"proceedPayment": "Proceder al Pago",
|
"proceedPayment": "Proceder al Pago",
|
||||||
"termsNote": "Al reservar, aceptas nuestros términos y condiciones.",
|
"termsAgreePart1": "Acepto los ",
|
||||||
|
"termsOfService": "Términos de Servicio",
|
||||||
|
"termsAgreePart2": " y la ",
|
||||||
|
"privacyPolicy": "Política de Privacidad",
|
||||||
|
"termsAgreePart3": ".",
|
||||||
"soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.",
|
"soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"nameRequired": "Por favor ingresa tu nombre completo",
|
"nameRequired": "Por favor ingresa tu nombre completo",
|
||||||
@@ -129,7 +133,8 @@
|
|||||||
"phoneRequired": "El número de teléfono es requerido",
|
"phoneRequired": "El número de teléfono es requerido",
|
||||||
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
|
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
|
||||||
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
|
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
|
||||||
"rucInvalidCheckDigit": "RUC inválido. Verifique el número."
|
"rucInvalidCheckDigit": "RUC inválido. Verifique el número.",
|
||||||
|
"termsRequired": "Debes aceptar los Términos de Servicio y la Política de Privacidad para continuar."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ export const ticketsApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
guestCreate: (data: {
|
||||||
|
eventId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
preferredLanguage?: 'en' | 'es';
|
||||||
|
adminNote?: string;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
checkPaymentStatus: (ticketId: string) =>
|
checkPaymentStatus: (ticketId: string) =>
|
||||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||||
@@ -550,6 +564,7 @@ export interface Ticket {
|
|||||||
checkedInByAdminId?: string;
|
checkedInByAdminId?: string;
|
||||||
qrCode: string;
|
qrCode: string;
|
||||||
adminNote?: string;
|
adminNote?: string;
|
||||||
|
isGuest?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
event?: Event;
|
event?: Event;
|
||||||
payment?: Payment;
|
payment?: Payment;
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
// All helpers pin the timezone to America/Asuncion so the output is identical
|
// All helpers pin the timezone to America/Asuncion so the output is identical
|
||||||
// on the server (often UTC) and the client (user's local TZ). This prevents
|
// on the server (often UTC) and the client (user's local TZ). This prevents
|
||||||
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
|
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
|
||||||
|
//
|
||||||
|
// IMPORTANT — parseDate() must be used instead of raw `new Date(str)` so that
|
||||||
|
// ISO-like strings without a timezone suffix (e.g. "2026-04-02T14:00:00") are
|
||||||
|
// always treated as UTC. Without this, the same string produces a different
|
||||||
|
// instant on server (Node TZ) vs client (browser / DevTools TZ).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const EVENT_TIMEZONE = 'America/Asuncion';
|
export const EVENT_TIMEZONE = 'America/Asuncion';
|
||||||
|
|
||||||
type Locale = 'en' | 'es';
|
type Locale = 'en' | 'es';
|
||||||
|
|
||||||
@@ -14,11 +19,29 @@ function pickLocale(locale: Locale): string {
|
|||||||
return locale === 'es' ? 'es-ES' : 'en-US';
|
return locale === 'es' ? 'es-ES' : 'en-US';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches ISO-like strings that have NO timezone indicator (Z, +HH:MM, etc.)
|
||||||
|
const NAIVE_ISO_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a date string into a deterministic Date object.
|
||||||
|
*
|
||||||
|
* If the string looks like an ISO datetime but lacks a timezone suffix it is
|
||||||
|
* ambiguous — `new Date()` would interpret it in the environment's local
|
||||||
|
* timezone which differs between Node (SSR) and the browser (hydration).
|
||||||
|
* We normalise by appending "Z" so parsing always targets UTC.
|
||||||
|
*/
|
||||||
|
export function parseDate(dateStr: string): Date {
|
||||||
|
if (NAIVE_ISO_RE.test(dateStr)) {
|
||||||
|
return new Date(dateStr + 'Z');
|
||||||
|
}
|
||||||
|
return new Date(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Sat, Feb 14" / "sáb, 14 feb"
|
* "Sat, Feb 14" / "sáb, 14 feb"
|
||||||
*/
|
*/
|
||||||
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
|
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
|
||||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -30,7 +53,7 @@ export function formatDateShort(dateStr: string, locale: Locale = 'en'): string
|
|||||||
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
|
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
|
||||||
*/
|
*/
|
||||||
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
|
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
|
||||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
@@ -43,7 +66,7 @@ export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
|
|||||||
* "February 14, 2026" / "14 de febrero de 2026" (no weekday)
|
* "February 14, 2026" / "14 de febrero de 2026" (no weekday)
|
||||||
*/
|
*/
|
||||||
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
|
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
|
||||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -55,7 +78,7 @@ export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string
|
|||||||
* "Feb 14, 2026" / "14 feb 2026"
|
* "Feb 14, 2026" / "14 feb 2026"
|
||||||
*/
|
*/
|
||||||
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
|
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
|
||||||
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -67,7 +90,7 @@ export function formatDateCompact(dateStr: string, locale: Locale = 'en'): strin
|
|||||||
* "04:30 PM" / "16:30"
|
* "04:30 PM" / "16:30"
|
||||||
*/
|
*/
|
||||||
export function formatTime(dateStr: string, locale: Locale = 'en'): string {
|
export function formatTime(dateStr: string, locale: Locale = 'en'): string {
|
||||||
return new Date(dateStr).toLocaleTimeString(pickLocale(locale), {
|
return parseDate(dateStr).toLocaleTimeString(pickLocale(locale), {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
timeZone: EVENT_TIMEZONE,
|
timeZone: EVENT_TIMEZONE,
|
||||||
@@ -78,7 +101,7 @@ export function formatTime(dateStr: string, locale: Locale = 'en'): string {
|
|||||||
* "Feb 14, 2026, 04:30 PM" — compact date + time combined
|
* "Feb 14, 2026, 04:30 PM" — compact date + time combined
|
||||||
*/
|
*/
|
||||||
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
|
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
|
||||||
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
return parseDate(dateStr).toLocaleString(pickLocale(locale), {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -92,7 +115,7 @@ export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
|
|||||||
* "Sat, Feb 14, 04:30 PM" — short date + time combined
|
* "Sat, Feb 14, 04:30 PM" — short date + time combined
|
||||||
*/
|
*/
|
||||||
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
|
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
|
||||||
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
return parseDate(dateStr).toLocaleString(pickLocale(locale), {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
Reference in New Issue
Block a user