Compare commits
5
Commits
backup-prod16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4945a87cf | ||
|
|
0b7f0a474e | ||
|
|
a70333f5e4 | ||
|
|
9cc330030b | ||
|
|
59acc32a46 |
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,8 @@ async function migrate() {
|
||||
banner_url TEXT,
|
||||
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
external_booking_url TEXT,
|
||||
presale_closure_enabled INTEGER,
|
||||
presale_close_minutes_before INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
@@ -107,6 +109,14 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
||||
@@ -510,6 +520,8 @@ async function migrate() {
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
)
|
||||
@@ -520,6 +532,14 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure site-wide defaults
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Legal pages table for admin-editable legal content
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||
@@ -730,6 +750,8 @@ async function migrate() {
|
||||
banner_url VARCHAR(500),
|
||||
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
external_booking_url VARCHAR(500),
|
||||
presale_closure_enabled INTEGER,
|
||||
presale_close_minutes_before INTEGER,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
@@ -743,6 +765,14 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
||||
@@ -1104,6 +1134,8 @@ async function migrate() {
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
updated_by UUID REFERENCES users(id)
|
||||
)
|
||||
@@ -1114,6 +1146,14 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure site-wide defaults
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Legal pages table for admin-editable legal content
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||
|
||||
@@ -88,6 +88,9 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
bannerUrl: text('banner_url'),
|
||||
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
externalBookingUrl: text('external_booking_url'),
|
||||
// Pre-sale closure: null = inherit the site_settings default
|
||||
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }),
|
||||
presaleCloseMinutesBefore: integer('presale_close_minutes_before'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -387,6 +390,9 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
||||
maintenanceMessage: text('maintenance_message'),
|
||||
maintenanceMessageEs: text('maintenance_message_es'),
|
||||
// Pre-sale closure defaults (events inherit these unless they override)
|
||||
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
presaleCloseMinutesBefore: integer('presale_close_minutes_before').notNull().default(120),
|
||||
// Metadata
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
@@ -476,6 +482,9 @@ export const pgEvents = pgTable('events', {
|
||||
bannerUrl: varchar('banner_url', { length: 500 }),
|
||||
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
|
||||
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
|
||||
// Pre-sale closure: null = inherit the site_settings default
|
||||
presaleClosureEnabled: pgInteger('presale_closure_enabled'),
|
||||
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
@@ -761,6 +770,9 @@ export const pgSiteSettings = pgTable('site_settings', {
|
||||
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
||||
maintenanceMessage: pgText('maintenance_message'),
|
||||
maintenanceMessageEs: pgText('maintenance_message_es'),
|
||||
// Pre-sale closure defaults (events inherit these unless they override)
|
||||
presaleClosureEnabled: pgInteger('presale_closure_enabled').notNull().default(1),
|
||||
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before').notNull().default(120),
|
||||
// Metadata
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolvePresaleClosure,
|
||||
isPresaleClosed,
|
||||
DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
} from './presale.js';
|
||||
|
||||
const START = '2030-01-01T20:00:00.000Z';
|
||||
const startMs = new Date(START).getTime();
|
||||
const minutes = (n: number) => n * 60_000;
|
||||
|
||||
describe('resolvePresaleClosure', () => {
|
||||
it('falls back to the built-in defaults when neither event nor settings specify anything', () => {
|
||||
const r = resolvePresaleClosure({ startDatetime: START }, null);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES));
|
||||
});
|
||||
|
||||
it('inherits from site settings when the event has null overrides', () => {
|
||||
const r = resolvePresaleClosure(
|
||||
{ startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null },
|
||||
{ presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 }
|
||||
);
|
||||
expect(r.minutesBefore).toBe(30);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs - minutes(30));
|
||||
});
|
||||
|
||||
it('lets the event override the site settings', () => {
|
||||
const r = resolvePresaleClosure(
|
||||
{ startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 },
|
||||
{ presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 }
|
||||
);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440));
|
||||
});
|
||||
|
||||
it('returns closesAt null when closure is disabled (site-wide or per event)', () => {
|
||||
expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull();
|
||||
expect(
|
||||
resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts PostgreSQL 0/1 integers and string minutes', () => {
|
||||
const r = resolvePresaleClosure(
|
||||
{ startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' },
|
||||
{ presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 }
|
||||
);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.minutesBefore).toBe(45);
|
||||
const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 });
|
||||
expect(off.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('closes at the event start when minutes is 0', () => {
|
||||
const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPresaleClosed', () => {
|
||||
const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 };
|
||||
|
||||
it('is open before the cutoff and closed from the cutoff onwards', () => {
|
||||
const cutoff = startMs - minutes(60);
|
||||
expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false);
|
||||
expect(isPresaleClosed(event, null, cutoff)).toBe(true);
|
||||
expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true);
|
||||
});
|
||||
|
||||
it('never closes when closure is disabled', () => {
|
||||
expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Pre-sale closure: online registration for an event closes a configurable
|
||||
* number of minutes before the event starts.
|
||||
*
|
||||
* Each event may override the site-wide default; a `null`/`undefined` value on
|
||||
* the event means "inherit from site_settings". Values arrive as booleans on
|
||||
* SQLite and as 0/1 integers on PostgreSQL, so both are accepted here.
|
||||
*/
|
||||
|
||||
export const DEFAULT_PRESALE_CLOSURE_ENABLED = true;
|
||||
export const DEFAULT_PRESALE_CLOSE_MINUTES = 120;
|
||||
|
||||
export interface PresaleEventLike {
|
||||
startDatetime: string | Date;
|
||||
presaleClosureEnabled?: boolean | number | null;
|
||||
presaleCloseMinutesBefore?: number | string | null;
|
||||
}
|
||||
|
||||
export interface PresaleSettingsLike {
|
||||
presaleClosureEnabled?: boolean | number | null;
|
||||
presaleCloseMinutesBefore?: number | string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedPresaleClosure {
|
||||
enabled: boolean;
|
||||
minutesBefore: number;
|
||||
/** When online registration closes, or null when closure is disabled. */
|
||||
closesAt: Date | null;
|
||||
}
|
||||
|
||||
function toBool(value: boolean | number | null | undefined): boolean | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return typeof value === 'number' ? value !== 0 : Boolean(value);
|
||||
}
|
||||
|
||||
function toMinutes(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const n = typeof value === 'string' ? parseInt(value, 10) : value;
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
export function resolvePresaleClosure(
|
||||
event: PresaleEventLike,
|
||||
settings?: PresaleSettingsLike | null
|
||||
): ResolvedPresaleClosure {
|
||||
const enabled =
|
||||
toBool(event.presaleClosureEnabled) ??
|
||||
toBool(settings?.presaleClosureEnabled) ??
|
||||
DEFAULT_PRESALE_CLOSURE_ENABLED;
|
||||
const minutesBefore =
|
||||
toMinutes(event.presaleCloseMinutesBefore) ??
|
||||
toMinutes(settings?.presaleCloseMinutesBefore) ??
|
||||
DEFAULT_PRESALE_CLOSE_MINUTES;
|
||||
|
||||
if (!enabled) return { enabled, minutesBefore, closesAt: null };
|
||||
|
||||
const startMs = new Date(event.startDatetime).getTime();
|
||||
if (!Number.isFinite(startMs)) return { enabled, minutesBefore, closesAt: null };
|
||||
|
||||
return { enabled, minutesBefore, closesAt: new Date(startMs - minutesBefore * 60_000) };
|
||||
}
|
||||
|
||||
export function isPresaleClosed(
|
||||
event: PresaleEventLike,
|
||||
settings?: PresaleSettingsLike | null,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
const { closesAt } = resolvePresaleClosure(event, settings);
|
||||
return closesAt !== null && closesAt.getTime() <= nowMs;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calcula
|
||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
import { resolvePresaleClosure } from '../lib/presale.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -19,10 +20,13 @@ interface UserContext {
|
||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// Helper to normalize event data for API response
|
||||
// PostgreSQL decimal returns strings, booleans are stored as integers
|
||||
function normalizeEvent(event: any) {
|
||||
// PostgreSQL decimal returns strings, booleans are stored as integers.
|
||||
// `settings` is the site_settings row; when given, the effective pre-sale
|
||||
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
|
||||
// booking API agree on when registration closes.
|
||||
function normalizeEvent(event: any, settings?: any) {
|
||||
if (!event) return event;
|
||||
return {
|
||||
const normalized = {
|
||||
...event,
|
||||
// Convert price from string/decimal to clean number
|
||||
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
||||
@@ -30,7 +34,15 @@ function normalizeEvent(event: any) {
|
||||
capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
|
||||
// Convert boolean integers to actual booleans for frontend
|
||||
externalBookingEnabled: Boolean(event.externalBookingEnabled),
|
||||
// Pre-sale overrides: null means "inherit the site default"
|
||||
presaleClosureEnabled: event.presaleClosureEnabled == null ? null : Boolean(event.presaleClosureEnabled),
|
||||
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore == null ? null : Number(event.presaleCloseMinutesBefore),
|
||||
};
|
||||
if (settings !== undefined) {
|
||||
const { closesAt } = resolvePresaleClosure(normalized, settings);
|
||||
return { ...normalized, presaleClosesAt: closesAt ? closesAt.toISOString() : null };
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Load every slug currently in use (canonical event slugs + historical aliases),
|
||||
@@ -138,8 +150,30 @@ const baseEventSchema = z.object({
|
||||
// External booking support - accept boolean or number (0/1 from DB)
|
||||
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
||||
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
// Pre-sale closure overrides - null/omitted means "inherit the site default"
|
||||
presaleClosureEnabled: z.union([z.boolean(), z.number(), z.null()])
|
||||
.transform((v) => (v === null ? null : normalizeBoolean(v)))
|
||||
.optional(),
|
||||
presaleCloseMinutesBefore: z.union([z.number(), z.string(), z.null()])
|
||||
.transform((v) => {
|
||||
if (v === null) return null;
|
||||
const n = typeof v === 'string' ? parseInt(v, 10) : v;
|
||||
return Number.isFinite(n) ? Math.floor(n) : NaN;
|
||||
})
|
||||
.pipe(z.number().int().min(0, 'Pre-sale closure time cannot be negative').nullable())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// When pre-sale closure is explicitly enabled on an event, the cutoff must be set too.
|
||||
const presaleRefine = {
|
||||
check: (data: { presaleClosureEnabled?: boolean | null; presaleCloseMinutesBefore?: number | null }) =>
|
||||
data.presaleClosureEnabled !== true || typeof data.presaleCloseMinutesBefore === 'number',
|
||||
options: {
|
||||
message: 'Pre-sale closure time is required when pre-sale closure is enabled',
|
||||
path: ['presaleCloseMinutesBefore'],
|
||||
},
|
||||
};
|
||||
|
||||
const createEventSchema = baseEventSchema.refine(
|
||||
(data) => {
|
||||
// If external booking is enabled, URL must be provided and must start with https://
|
||||
@@ -152,7 +186,7 @@ const createEventSchema = baseEventSchema.refine(
|
||||
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
||||
path: ['externalBookingUrl'],
|
||||
}
|
||||
);
|
||||
).refine(presaleRefine.check, presaleRefine.options);
|
||||
|
||||
const updateEventSchema = baseEventSchema.partial().refine(
|
||||
(data) => {
|
||||
@@ -166,7 +200,7 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
||||
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
||||
path: ['externalBookingUrl'],
|
||||
}
|
||||
);
|
||||
).refine(presaleRefine.check, presaleRefine.options);
|
||||
|
||||
// Get all events (public)
|
||||
eventsRouter.get('/', async (c) => {
|
||||
@@ -235,8 +269,9 @@ eventsRouter.get('/', async (c) => {
|
||||
});
|
||||
}
|
||||
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const eventsWithCounts = result.map((event: any) => {
|
||||
const normalized = normalizeEvent(event);
|
||||
const normalized = normalizeEvent(event, siteSettingsRow);
|
||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...normalized,
|
||||
@@ -269,7 +304,7 @@ eventsRouter.get('/:id', async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow());
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
return c.json({
|
||||
event: {
|
||||
@@ -281,10 +316,15 @@ eventsRouter.get('/:id', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
async function getSiteTimezone(): Promise<string> {
|
||||
// Single site_settings row (or null when none has been created yet)
|
||||
async function getSiteSettingsRow(): Promise<any | null> {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
return settings || null;
|
||||
}
|
||||
|
||||
function siteTimezoneOf(settings: any | null): string {
|
||||
return settings?.timezone || 'America/Asuncion';
|
||||
}
|
||||
|
||||
@@ -320,7 +360,7 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
||||
}
|
||||
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const normalized = normalizeEvent(event);
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow());
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
@@ -389,7 +429,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
// If we have a valid featured event, return it
|
||||
if (featuredEvent) {
|
||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||
const normalized = normalizeEvent(featuredEvent);
|
||||
const normalized = normalizeEvent(featuredEvent, settings);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
@@ -417,7 +457,8 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
const tz = await getSiteTimezone();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
@@ -442,7 +483,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
revalidateFrontendCache();
|
||||
|
||||
// Return normalized event data
|
||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
||||
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201);
|
||||
});
|
||||
|
||||
// Update event (admin/organizer only)
|
||||
@@ -458,7 +499,8 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const tz = await getSiteTimezone();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
// Convert data for database compatibility
|
||||
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
||||
// Slug changes are handled explicitly below to manage aliases
|
||||
@@ -517,7 +559,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ event: normalizeEvent(updated) });
|
||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow) });
|
||||
});
|
||||
|
||||
// Delete event (admin only)
|
||||
@@ -634,6 +676,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
bannerUrl: existing.bannerUrl,
|
||||
externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
|
||||
externalBookingUrl: existing.externalBookingUrl,
|
||||
presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null)
|
||||
presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { DEFAULT_PRESALE_CLOSURE_ENABLED, DEFAULT_PRESALE_CLOSE_MINUTES } from '../lib/presale.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -43,8 +44,26 @@ const updateSiteSettingsSchema = z.object({
|
||||
maintenanceMode: z.boolean().optional(),
|
||||
maintenanceMessage: z.string().optional().nullable(),
|
||||
maintenanceMessageEs: z.string().optional().nullable(),
|
||||
// Pre-sale closure defaults inherited by events that don't override them
|
||||
presaleClosureEnabled: z.boolean().optional(),
|
||||
presaleCloseMinutesBefore: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
// Booleans are stored as 0/1 integers on PostgreSQL; hand the frontend real booleans.
|
||||
function normalizeSettings(row: any) {
|
||||
if (!row) return row;
|
||||
return {
|
||||
...row,
|
||||
maintenanceMode: Boolean(row.maintenanceMode),
|
||||
presaleClosureEnabled: row.presaleClosureEnabled == null
|
||||
? DEFAULT_PRESALE_CLOSURE_ENABLED
|
||||
: Boolean(row.presaleClosureEnabled),
|
||||
presaleCloseMinutesBefore: row.presaleCloseMinutesBefore == null
|
||||
? DEFAULT_PRESALE_CLOSE_MINUTES
|
||||
: Number(row.presaleCloseMinutesBefore),
|
||||
};
|
||||
}
|
||||
|
||||
// Get site settings (public - needed for frontend timezone)
|
||||
siteSettingsRouter.get('/', async (c) => {
|
||||
const settings = await dbGet(
|
||||
@@ -69,11 +88,13 @@ siteSettingsRouter.get('/', async (c) => {
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
presaleClosureEnabled: DEFAULT_PRESALE_CLOSURE_ENABLED,
|
||||
presaleCloseMinutesBefore: DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ settings });
|
||||
return c.json({ settings: normalizeSettings(settings) });
|
||||
});
|
||||
|
||||
// Get available timezones
|
||||
@@ -145,13 +166,15 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
||||
maintenanceMessage: data.maintenanceMessage || null,
|
||||
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
||||
presaleClosureEnabled: toDbBool(data.presaleClosureEnabled ?? DEFAULT_PRESALE_CLOSURE_ENABLED),
|
||||
presaleCloseMinutesBefore: data.presaleCloseMinutesBefore ?? DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
|
||||
return c.json({ settings: normalizeSettings(newSettings), message: 'Settings created successfully' }, 201);
|
||||
}
|
||||
|
||||
// Validate featured event if provided
|
||||
@@ -174,6 +197,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
if (typeof data.maintenanceMode === 'boolean') {
|
||||
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
|
||||
}
|
||||
if (typeof data.presaleClosureEnabled === 'boolean') {
|
||||
updateData.presaleClosureEnabled = toDbBool(data.presaleClosureEnabled);
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(siteSettings)
|
||||
@@ -184,12 +210,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
// Revalidate frontend cache if featured event changed
|
||||
if (data.featuredEventId !== undefined) {
|
||||
// Revalidate frontend cache if featured event changed or the pre-sale
|
||||
// defaults changed (public event pages embed the computed cutoff).
|
||||
if (
|
||||
data.featuredEventId !== undefined ||
|
||||
data.presaleClosureEnabled !== undefined ||
|
||||
data.presaleCloseMinutesBefore !== undefined
|
||||
) {
|
||||
revalidateFrontendCache();
|
||||
}
|
||||
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
return c.json({ settings: normalizeSettings(updated), message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
// Set featured event (admin only) - convenience endpoint for event editor
|
||||
|
||||
@@ -11,6 +11,7 @@ import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||
import { isPresaleClosed } from '../lib/presale.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -111,6 +112,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||
}
|
||||
|
||||
// Pre-sale closure: online registration stops N minutes before the event
|
||||
// starts (per-event override, else the site-wide default). Staff/door and
|
||||
// admin ticket creation use separate endpoints and are not gated.
|
||||
const siteSettingsRow = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
if (isPresaleClosed(event, siteSettingsRow)) {
|
||||
return c.json({ error: 'Registration for this event is closed' }, 400);
|
||||
}
|
||||
|
||||
// Validate the requested payment method is actually enabled for this event
|
||||
// (merge global options with any event-level overrides; override wins when not null)
|
||||
const globalPaymentOptions = await dbGet<any>(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils';
|
||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
import type {
|
||||
@@ -108,6 +108,14 @@ export default function BookingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-sale closure: the booking API rejects after the cutoff, so
|
||||
// bounce back to the event page instead of showing a dead form.
|
||||
if (isPresaleClosed(eventRes.event)) {
|
||||
toast.error(t('events.details.registrationClosed'));
|
||||
router.push(`/events/${eventRes.event.slug}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Server-authoritative availability — same formula the booking API
|
||||
// enforces, so a sold-out event is caught here, not at submit time.
|
||||
if (isEventSoldOut(eventRes.event)) {
|
||||
@@ -364,11 +372,18 @@ export default function BookingPage() {
|
||||
toast.success(t('booking.success.message'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
const message = String(error?.message || '');
|
||||
// Pre-sale closed while the form was open: send the user back to the
|
||||
// event page, which now shows the "Registration Closed" state.
|
||||
if (/registration .*closed/i.test(message)) {
|
||||
toast.error(t('events.details.registrationClosed'));
|
||||
if (event?.slug) router.push(`/events/${event.slug}`);
|
||||
return;
|
||||
}
|
||||
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
||||
// Capacity race on the last seats: refresh availability so the page
|
||||
// reflects reality (sold-out block / lower quantity cap) instead of the
|
||||
// stale counts loaded when the form was opened.
|
||||
const message = String(error?.message || '');
|
||||
if (/sold out|seats available/i.test(message)) {
|
||||
try {
|
||||
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
@@ -69,7 +69,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
|
||||
// Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent
|
||||
const presaleClosed = mounted ? isPresaleClosed(event) : false;
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted');
|
||||
// Effective lead time (event override or site default), derived from the server cutoff
|
||||
const presaleLeadMinutes = event.presaleClosesAt
|
||||
? Math.max(0, Math.round((parseDate(event.startDatetime).getTime() - parseDate(event.presaleClosesAt).getTime()) / 60_000))
|
||||
: null;
|
||||
|
||||
// Booking card content - reused for mobile and desktop positions
|
||||
const BookingCardContent = () => (
|
||||
@@ -143,12 +149,24 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
<Button className="w-full" size="lg" disabled>
|
||||
{isPastEvent
|
||||
? t('events.details.eventEnded')
|
||||
: presaleClosed
|
||||
? t('events.details.registrationClosed')
|
||||
: isSoldOut
|
||||
? t('events.details.soldOut')
|
||||
: t('events.details.cancelled')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canBook && !event.externalBookingEnabled && presaleLeadMinutes !== null && (
|
||||
<p className="mt-3 text-center text-xs text-gray-400">
|
||||
{presaleLeadMinutes > 0
|
||||
? t('events.details.presaleClosesBefore', {
|
||||
duration: formatDurationWords(presaleLeadMinutes, locale as 'en' | 'es'),
|
||||
})
|
||||
: t('events.details.presaleClosesAtStart')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!event.externalBookingEnabled && (
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface Event {
|
||||
bannerUrl?: string;
|
||||
availableSeats?: number;
|
||||
bookedCount?: number;
|
||||
presaleClosesAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -125,6 +126,7 @@ function generateEventJsonLd(event: Event) {
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
validFrom: new Date().toISOString(),
|
||||
...(event.presaleClosesAt ? { validThrough: event.presaleClosesAt } : {}),
|
||||
},
|
||||
image: event.bannerUrl
|
||||
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
||||
|
||||
@@ -6,10 +6,11 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import DurationInput from '@/components/admin/DurationInput';
|
||||
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
|
||||
|
||||
interface EventFormData {
|
||||
title: string;
|
||||
@@ -30,14 +31,26 @@ interface EventFormData {
|
||||
bannerUrl: string;
|
||||
externalBookingEnabled: boolean;
|
||||
externalBookingUrl: string;
|
||||
presaleClosureEnabled: boolean;
|
||||
presaleCloseMinutesBefore: number;
|
||||
}
|
||||
|
||||
// Site-wide pre-sale defaults, used to pre-fill events that haven't overridden them
|
||||
interface PresaleDefaults {
|
||||
enabled: boolean;
|
||||
minutesBefore: number;
|
||||
}
|
||||
|
||||
const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefore: 120 };
|
||||
|
||||
const EMPTY_FORM: EventFormData = {
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
|
||||
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
|
||||
};
|
||||
|
||||
function isoToLocalDatetime(isoString: string): string {
|
||||
@@ -89,6 +102,30 @@ export default function EventFormModal({
|
||||
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settingFeatured, setSettingFeatured] = useState(false);
|
||||
const [presaleDefaults, setPresaleDefaults] = useState<PresaleDefaults>(FALLBACK_PRESALE_DEFAULTS);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
// Site defaults fill in whatever the event hasn't overridden (null = inherit)
|
||||
siteSettingsApi.get()
|
||||
.then(({ settings }) => {
|
||||
if (cancelled) return;
|
||||
const defaults: PresaleDefaults = {
|
||||
enabled: settings.presaleClosureEnabled ?? FALLBACK_PRESALE_DEFAULTS.enabled,
|
||||
minutesBefore: settings.presaleCloseMinutesBefore ?? FALLBACK_PRESALE_DEFAULTS.minutesBefore,
|
||||
};
|
||||
setPresaleDefaults(defaults);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
presaleClosureEnabled: event?.presaleClosureEnabled ?? defaults.enabled,
|
||||
presaleCloseMinutesBefore: event?.presaleCloseMinutesBefore ?? defaults.minutesBefore,
|
||||
}));
|
||||
})
|
||||
.catch(() => { /* keep fallback defaults */ });
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, event]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -104,6 +141,8 @@ export default function EventFormModal({
|
||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||
externalBookingUrl: event.externalBookingUrl || '',
|
||||
presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled,
|
||||
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore,
|
||||
});
|
||||
loadSlugAliases(event.id);
|
||||
} else {
|
||||
@@ -161,6 +200,14 @@ export default function EventFormModal({
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
formData.presaleClosureEnabled &&
|
||||
(!Number.isFinite(formData.presaleCloseMinutesBefore) || formData.presaleCloseMinutesBefore < 0)
|
||||
) {
|
||||
toast.error('Pre-sale closure time must be zero or more');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const eventData: Partial<Event> = {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
@@ -172,6 +219,9 @@ export default function EventFormModal({
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
externalBookingEnabled: formData.externalBookingEnabled,
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
// Saving from the modal always writes explicit values (overrides the site default)
|
||||
presaleClosureEnabled: formData.presaleClosureEnabled,
|
||||
presaleCloseMinutesBefore: formData.presaleCloseMinutesBefore,
|
||||
};
|
||||
if (event) {
|
||||
// Only send slug when editing so creates still auto-generate from title
|
||||
@@ -346,6 +396,35 @@ export default function EventFormModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Pre-sale Closure</label>
|
||||
<p className="text-xs text-gray-500">Stop online registration before the event starts</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setFormData({ ...formData, presaleClosureEnabled: !formData.presaleClosureEnabled })}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
formData.presaleClosureEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
formData.presaleClosureEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{formData.presaleClosureEnabled && (
|
||||
<DurationInput label="Close pre-sale this long before the start"
|
||||
valueMinutes={formData.presaleCloseMinutesBefore}
|
||||
onChange={(minutes) => setFormData({ ...formData, presaleCloseMinutesBefore: minutes })} />
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
Site default: {presaleDefaults.enabled
|
||||
? `closes ${formatDurationWords(presaleDefaults.minutesBefore)} before the start`
|
||||
: 'off'}
|
||||
{' '}(Admin › Settings › General).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MediaPicker value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={event?.id} relatedType="event" />
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type SyntheticEvent } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { CheckCircleIcon, XCircleIcon, BanknotesIcon } from '@heroicons/react/24/outline';
|
||||
import type { SessionEntry } from './SessionSheet';
|
||||
|
||||
// Full-screen outcome after a door action. At a loud, dark door a row flash and
|
||||
// a small toast are too easy to miss; a screen that is entirely green or
|
||||
// entirely red answers "is this person in or not?" from across the table.
|
||||
//
|
||||
// It never traps the queue: tapping anywhere closes it, and it closes itself
|
||||
// after a few seconds. The undo affordance survives the close — the page shows
|
||||
// the bottom Undo toast for whatever is left of the window.
|
||||
|
||||
export type DoorResultKind = 'success' | 'already_in' | 'not_found' | 'unpaid' | 'failed';
|
||||
|
||||
export interface DoorResult {
|
||||
kind: DoorResultKind;
|
||||
/** Headline: the attendee's name, or a short verdict when there is no one. */
|
||||
name: string;
|
||||
/** Second line: "Checked in 19:42 · paid cash", "Already in at 19:42", "Collect ₲60.000". */
|
||||
detail?: string;
|
||||
/** Success only — drives Undo and the post-close toast. */
|
||||
entry?: SessionEntry;
|
||||
}
|
||||
|
||||
// Success needs no reading; the red ones carry a time or an amount the staff
|
||||
// member has to relay to the person, so they get a little longer.
|
||||
export const RESULT_AUTO_CLOSE_MS: Record<DoorResultKind, number> = {
|
||||
success: 4000,
|
||||
already_in: 6000,
|
||||
not_found: 6000,
|
||||
unpaid: 6000,
|
||||
failed: 6000,
|
||||
};
|
||||
|
||||
const SURFACE: Record<DoorResultKind, { bg: string; text: string; icon: typeof CheckCircleIcon; title: string }> = {
|
||||
success: { bg: 'bg-emerald-600', text: 'text-emerald-700', icon: CheckCircleIcon, title: 'Checked in' },
|
||||
already_in: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Already checked in' },
|
||||
not_found: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Not found' },
|
||||
unpaid: { bg: 'bg-amber-600', text: 'text-amber-700', icon: BanknotesIcon, title: 'Payment due' },
|
||||
failed: { bg: 'bg-red-700', text: 'text-red-800', icon: XCircleIcon, title: 'NOT checked in' },
|
||||
};
|
||||
|
||||
export function ResultScreen({
|
||||
result,
|
||||
onClose,
|
||||
onUndo,
|
||||
}: {
|
||||
result: DoorResult;
|
||||
onClose: () => void;
|
||||
onUndo: () => void;
|
||||
}) {
|
||||
const surface = SURFACE[result.kind];
|
||||
const duration = RESULT_AUTO_CLOSE_MS[result.kind];
|
||||
// Toggled after mount so the fade-in and the countdown bar both animate from
|
||||
// their starting state instead of appearing already finished.
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
// Keyed on the result identity: a success that flips to failed restarts both
|
||||
// the fade and the countdown for the new state.
|
||||
const identity = `${result.kind}:${result.entry?.idempotencyKey ?? result.name}`;
|
||||
|
||||
useEffect(() => {
|
||||
setShown(false);
|
||||
const raf = requestAnimationFrame(() => setShown(true));
|
||||
const timer = setTimeout(onClose, duration);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity]);
|
||||
|
||||
const stop = (e: SyntheticEvent) => e.stopPropagation();
|
||||
const Icon = surface.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="assertive"
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
'fixed inset-0 z-50 flex flex-col text-white select-none transition-opacity duration-150',
|
||||
surface.bg,
|
||||
shown ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
|
||||
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
|
||||
<Icon className="w-16 h-16" />
|
||||
</div>
|
||||
<p className="text-sm uppercase tracking-widest text-white/70 mb-2">{surface.title}</p>
|
||||
<h2 className="text-3xl font-bold leading-tight break-words max-w-full">{result.name}</h2>
|
||||
{result.detail && <p className="text-white/85 text-xl mt-3">{result.detail}</p>}
|
||||
<p className="text-white/50 text-sm mt-8">Tap anywhere to continue</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 flex gap-3">
|
||||
{result.kind === 'success' && (
|
||||
<button
|
||||
onClick={(e) => { stop(e); onUndo(); }}
|
||||
className="flex-1 min-h-[56px] rounded-2xl bg-white/20 text-white text-xl font-bold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { stop(e); onClose(); }}
|
||||
className={clsx(
|
||||
'flex-1 min-h-[56px] rounded-2xl bg-white text-xl font-bold active:scale-[0.98] transition-transform',
|
||||
surface.text,
|
||||
)}
|
||||
>
|
||||
{result.kind === 'unpaid' ? 'Collect' : 'Close'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Countdown to auto-close, so the screen vanishing never surprises anyone. */}
|
||||
<div className="h-1 bg-white/20">
|
||||
<div
|
||||
key={identity}
|
||||
className="h-full bg-white/70"
|
||||
style={{
|
||||
width: shown ? '0%' : '100%',
|
||||
transition: shown ? `width ${duration}ms linear` : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface SessionEntry {
|
||||
entry: 'scan' | 'search' | 'walkin';
|
||||
method: DoorPaymentMethod | null;
|
||||
amount: number;
|
||||
/** Epoch ms the action was fired; the undo window is measured from here. */
|
||||
startedAt: number;
|
||||
undone: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { QRScannerOverlay } from './_components/QRScannerOverlay';
|
||||
import { AttendeeRow } from './_components/AttendeeRow';
|
||||
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
|
||||
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
|
||||
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Door check-in screen
|
||||
@@ -90,6 +91,14 @@ export default function AdminDoorPage() {
|
||||
const [walkInDraft, setWalkInDraft] = useState<WalkInDraft>(emptyWalkIn());
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const [sessionOpen, setSessionOpen] = useState(false);
|
||||
// Full-screen outcome of the last action. Mirrored in a ref because the
|
||||
// background write in runAction must read the *current* screen when it fails,
|
||||
// not the one captured when it started.
|
||||
const [result, setResult] = useState<DoorResult | null>(null);
|
||||
const resultRef = useRef<DoorResult | null>(null);
|
||||
// Undo toast per action, so a write that fails after the toast is up can
|
||||
// pull it down instead of offering to undo something that never happened.
|
||||
const undoToastIdsRef = useRef(new Map<string, string>());
|
||||
|
||||
// ── Session bookkeeping ──
|
||||
const [sessionEntries, setSessionEntries] = useState<SessionEntry[]>([]);
|
||||
@@ -256,8 +265,8 @@ export default function AdminDoorPage() {
|
||||
);
|
||||
|
||||
const showUndoToast = useCallback(
|
||||
(message: string, entry: SessionEntry) => {
|
||||
toast.custom(
|
||||
(message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => {
|
||||
const id = toast.custom(
|
||||
(t) => (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -284,12 +293,45 @@ export default function AdminDoorPage() {
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ duration: UNDO_WINDOW_MS, position: 'bottom-center' },
|
||||
{ duration: durationMs, position: 'bottom-center' },
|
||||
);
|
||||
undoToastIdsRef.current.set(entry.idempotencyKey, id);
|
||||
},
|
||||
[handleUndo],
|
||||
);
|
||||
|
||||
// ─── Full-screen result ──────────────────────────────────────
|
||||
const showResult = useCallback((next: DoorResult) => {
|
||||
resultRef.current = next;
|
||||
setResult(next);
|
||||
}, []);
|
||||
|
||||
// Closing a success hands the undo affordance to the bottom toast for
|
||||
// whatever remains of the window — never both at once (the toast layer sits
|
||||
// above the overlay and would show a second Undo on top of the first).
|
||||
const closeResult = useCallback(
|
||||
(opts?: { skipUndoToast?: boolean }) => {
|
||||
const closing = resultRef.current;
|
||||
resultRef.current = null;
|
||||
setResult(null);
|
||||
focusInput();
|
||||
|
||||
if (opts?.skipUndoToast || closing?.kind !== 'success' || !closing.entry) return;
|
||||
const remaining = UNDO_WINDOW_MS - (Date.now() - closing.entry.startedAt);
|
||||
if (remaining < 500) return;
|
||||
const suffix = closing.entry.method ? `, ${METHOD_PAST_TENSE[closing.entry.method]}` : '';
|
||||
showUndoToast(`${closing.entry.name} checked in${suffix}`, closing.entry, remaining);
|
||||
},
|
||||
[focusInput, showUndoToast],
|
||||
);
|
||||
|
||||
const undoFromResult = useCallback(() => {
|
||||
const entry = resultRef.current?.entry;
|
||||
// handleUndo announces itself with its own toast; don't also queue the undo one.
|
||||
closeResult({ skipUndoToast: true });
|
||||
if (entry) handleUndo(entry);
|
||||
}, [closeResult, handleUndo]);
|
||||
|
||||
// ─── The one write path ──────────────────────────────────────
|
||||
// Every completed action on this screen — scan, tap, collect, walk-in — flows
|
||||
// through here so the flash, the counter, the session feed and the undo all
|
||||
@@ -321,6 +363,7 @@ export default function AdminDoorPage() {
|
||||
entry: opts.entry,
|
||||
method: opts.payment?.method ?? null,
|
||||
amount: opts.payment?.amount ?? 0,
|
||||
startedAt: now.getTime(),
|
||||
undone: false,
|
||||
failed: false,
|
||||
};
|
||||
@@ -355,7 +398,12 @@ export default function AdminDoorPage() {
|
||||
playSuccessSound();
|
||||
|
||||
const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : '';
|
||||
showUndoToast(`${opts.displayName} checked in${paidSuffix}`, sessionEntry);
|
||||
showResult({
|
||||
kind: 'success',
|
||||
name: opts.displayName,
|
||||
detail: `Checked in ${clockTime(now)}${paidSuffix}`,
|
||||
entry: sessionEntry,
|
||||
});
|
||||
|
||||
setQuery('');
|
||||
setExpandedId(null);
|
||||
@@ -395,6 +443,20 @@ export default function AdminDoorPage() {
|
||||
if (previousRow) upsertAttendee(previousRow);
|
||||
playErrorSound();
|
||||
vibrate([100, 50, 100]);
|
||||
// If the green screen for this very action is still up, turn it red in
|
||||
// place; the toast below covers the case where it has already closed.
|
||||
const staleUndo = undoToastIdsRef.current.get(idempotencyKey);
|
||||
if (staleUndo) {
|
||||
toast.dismiss(staleUndo);
|
||||
undoToastIdsRef.current.delete(idempotencyKey);
|
||||
}
|
||||
if (resultRef.current?.entry?.idempotencyKey === idempotencyKey) {
|
||||
showResult({
|
||||
kind: 'failed',
|
||||
name: opts.displayName,
|
||||
detail: error?.message || 'The check-in did not reach the server. Try again.',
|
||||
});
|
||||
}
|
||||
toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), {
|
||||
duration: 12000,
|
||||
position: 'bottom-center',
|
||||
@@ -404,7 +466,7 @@ export default function AdminDoorPage() {
|
||||
setBusyId((current) => (current === opts.busyKey ? null : current));
|
||||
}
|
||||
},
|
||||
[data?.attendees, patchAttendee, upsertAttendee, showUndoToast, focusInput, loadAttendees],
|
||||
[data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees],
|
||||
);
|
||||
|
||||
// ─── Row interactions ────────────────────────────────────────
|
||||
@@ -488,19 +550,17 @@ export default function AdminDoorPage() {
|
||||
if (!attendee) {
|
||||
playErrorSound();
|
||||
vibrate([100, 50, 100]);
|
||||
toast.error('Ticket not found for this event', { position: 'bottom-center', duration: 6000 });
|
||||
focusInput();
|
||||
showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (attendee.checkedIn) {
|
||||
playErrorSound();
|
||||
vibrate([100, 50, 100]);
|
||||
toast(`${attendee.fullName} already checked in${attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''}`, {
|
||||
icon: 'ℹ️',
|
||||
position: 'bottom-center',
|
||||
duration: 6000,
|
||||
});
|
||||
const at = attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : '';
|
||||
const by = attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : '';
|
||||
showResult({ kind: 'already_in', name: attendee.fullName, detail: `Already checked in${at}${by}` });
|
||||
// Their row sits open underneath, so closing lands on the same person.
|
||||
setQuery(attendee.fullName);
|
||||
setExpandedId(attendee.ticketId);
|
||||
return;
|
||||
@@ -508,13 +568,14 @@ export default function AdminDoorPage() {
|
||||
|
||||
if (attendee.paymentStatus === 'unpaid') {
|
||||
// Checking an unpaid ticket in silently would walk the money out the
|
||||
// door. Surface them with the tenders open instead.
|
||||
// door. Surface them with the tenders open instead: "Collect" closes
|
||||
// the screen straight onto the payment buttons.
|
||||
playErrorSound();
|
||||
vibrate(200);
|
||||
toast(`Collect ${formatCurrency(attendee.amountDue || price, currency)} from ${attendee.fullName}`, {
|
||||
icon: '💰',
|
||||
position: 'bottom-center',
|
||||
duration: 8000,
|
||||
showResult({
|
||||
kind: 'unpaid',
|
||||
name: attendee.fullName,
|
||||
detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`,
|
||||
});
|
||||
setQuery(attendee.fullName);
|
||||
setExpandedId(attendee.ticketId);
|
||||
@@ -529,7 +590,7 @@ export default function AdminDoorPage() {
|
||||
busyKey: attendee.ticketId,
|
||||
});
|
||||
},
|
||||
[data?.attendees, runAction, focusInput, price, currency],
|
||||
[data?.attendees, runAction, showResult, price, currency],
|
||||
);
|
||||
|
||||
// ─── Session summary ─────────────────────────────────────────
|
||||
@@ -732,6 +793,8 @@ export default function AdminDoorPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{result && <ResultScreen result={result} onClose={closeResult} onUndo={undoFromResult} />}
|
||||
|
||||
{sessionOpen && (
|
||||
<SessionSheet
|
||||
entries={sessionEntries}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import DurationInput from '@/components/admin/DurationInput';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
TicketIcon,
|
||||
GlobeAltIcon,
|
||||
ClockIcon,
|
||||
EnvelopeIcon,
|
||||
@@ -47,6 +49,8 @@ export default function AdminSettingsPage() {
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
presaleClosureEnabled: true,
|
||||
presaleCloseMinutesBefore: 120,
|
||||
});
|
||||
|
||||
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
|
||||
@@ -508,6 +512,69 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pre-sale Closure defaults */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<TicketIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Cierre de Preventa' : 'Pre-sale Closure'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Valor por defecto para los eventos. Cada evento puede cambiarlo en su ventana de edición.'
|
||||
: 'Default for events. Each event can override this in its edit dialog.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg mb-4">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{locale === 'es' ? 'Cerrar la preventa antes del evento' : 'Close pre-sale before the event starts'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{settings.presaleClosureEnabled
|
||||
? (locale === 'es' ? 'Las inscripciones en línea se cierran antes del inicio' : 'Online registration stops before the start time')
|
||||
: (locale === 'es' ? 'Las inscripciones siguen abiertas hasta el inicio' : 'Registration stays open until the event starts')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSetting('presaleClosureEnabled', !settings.presaleClosureEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.presaleClosureEnabled ? 'bg-green-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.presaleClosureEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.presaleClosureEnabled && (
|
||||
<div className="max-w-md">
|
||||
<DurationInput
|
||||
label={locale === 'es' ? 'Cerrar la preventa este tiempo antes del inicio' : 'Close pre-sale this long before the start'}
|
||||
valueMinutes={settings.presaleCloseMinutesBefore}
|
||||
onChange={(minutes) => updateSetting('presaleCloseMinutesBefore', minutes)}
|
||||
unitLabels={locale === 'es'
|
||||
? { minutes: 'minutos', hours: 'horas', days: 'días' }
|
||||
: { minutes: 'minutes', hours: 'hours', days: 'days' }}
|
||||
helper={locale === 'es'
|
||||
? 'Se aplica a los eventos que no tienen su propia configuración.'
|
||||
: 'Applies to events that have not set their own value.'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Maintenance Mode */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { DurationUnit, durationToMinutes, minutesToDuration } from '@/lib/utils';
|
||||
|
||||
interface DurationInputProps {
|
||||
label?: string;
|
||||
/** Duration in minutes (the stored unit). */
|
||||
valueMinutes: number;
|
||||
onChange: (minutes: number) => void;
|
||||
disabled?: boolean;
|
||||
helper?: string;
|
||||
/** Unit labels, overridable for Spanish admin pages. */
|
||||
unitLabels?: Record<DurationUnit, string>;
|
||||
}
|
||||
|
||||
const DEFAULT_UNIT_LABELS: Record<DurationUnit, string> = {
|
||||
minutes: 'minutes',
|
||||
hours: 'hours',
|
||||
days: 'days',
|
||||
};
|
||||
|
||||
/**
|
||||
* Number + unit (minutes / hours / days) picker that always reports minutes.
|
||||
* The unit is local UI state so switching hours -> minutes keeps the typed
|
||||
* number rather than the stored value.
|
||||
*/
|
||||
export default function DurationInput({
|
||||
label,
|
||||
valueMinutes,
|
||||
onChange,
|
||||
disabled,
|
||||
helper,
|
||||
unitLabels = DEFAULT_UNIT_LABELS,
|
||||
}: DurationInputProps) {
|
||||
const initial = minutesToDuration(valueMinutes);
|
||||
const [unit, setUnit] = useState<DurationUnit>(initial.unit);
|
||||
const [value, setValue] = useState<string>(String(initial.value));
|
||||
|
||||
// Re-sync when the parent swaps in a new stored value (e.g. modal reopened
|
||||
// for another event) that doesn't match what this input last reported.
|
||||
useEffect(() => {
|
||||
if (durationToMinutes(Number(value), unit) === valueMinutes) return;
|
||||
const next = minutesToDuration(valueMinutes);
|
||||
setUnit(next.unit);
|
||||
setValue(String(next.value));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueMinutes]);
|
||||
|
||||
const emit = (nextValue: string, nextUnit: DurationUnit) => {
|
||||
const n = Number(nextValue);
|
||||
onChange(durationToMinutes(Number.isFinite(n) ? n : 0, nextUnit));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label={label}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
emit(e.target.value, unit);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={unit}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const nextUnit = e.target.value as DurationUnit;
|
||||
setUnit(nextUnit);
|
||||
emit(value, nextUnit);
|
||||
}}
|
||||
className="px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:opacity-50"
|
||||
>
|
||||
{(Object.keys(unitLabels) as DurationUnit[]).map((u) => (
|
||||
<option key={u} value={u}>{unitLabels[u]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{helper && <p className="text-xs text-gray-500 mt-1">{helper}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,10 @@
|
||||
"spotsLeft": "spots left",
|
||||
"soldOut": "Sold Out",
|
||||
"cancelled": "Cancelled",
|
||||
"eventEnded": "Event Ended"
|
||||
"eventEnded": "Event Ended",
|
||||
"registrationClosed": "Registration Closed",
|
||||
"presaleClosesBefore": "Pre-sale registration closes {duration} before the event begins.",
|
||||
"presaleClosesAtStart": "Pre-sale registration closes when the event begins."
|
||||
},
|
||||
"booking": {
|
||||
"join": "Join Event",
|
||||
|
||||
@@ -88,7 +88,10 @@
|
||||
"spotsLeft": "lugares disponibles",
|
||||
"soldOut": "Agotado",
|
||||
"cancelled": "Cancelado",
|
||||
"eventEnded": "Evento Finalizado"
|
||||
"eventEnded": "Evento Finalizado",
|
||||
"registrationClosed": "Inscripciones Cerradas",
|
||||
"presaleClosesBefore": "La preventa cierra {duration} antes de que comience el evento.",
|
||||
"presaleClosesAtStart": "La preventa cierra cuando comienza el evento."
|
||||
},
|
||||
"booking": {
|
||||
"join": "Unirse al Evento",
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface Event {
|
||||
bannerUrl?: string;
|
||||
externalBookingEnabled?: boolean;
|
||||
externalBookingUrl?: string;
|
||||
presaleClosureEnabled?: boolean | null; // null = inherit the site default
|
||||
presaleCloseMinutesBefore?: number | null; // null = inherit the site default
|
||||
presaleClosesAt?: string | null; // server-computed cutoff (ISO); null = never closes
|
||||
bookedCount?: number; // paid seats (confirmed + checked_in)
|
||||
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
|
||||
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
|
||||
@@ -484,6 +487,8 @@ export interface SiteSettings {
|
||||
maintenanceMode: boolean;
|
||||
maintenanceMessage?: string | null;
|
||||
maintenanceMessageEs?: string | null;
|
||||
presaleClosureEnabled: boolean;
|
||||
presaleCloseMinutesBefore: number;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
@@ -235,3 +235,49 @@ export function isEventSoldOut(event: {
|
||||
}): boolean {
|
||||
return eventSpotsLeft(event) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once online registration has closed for an event. Relies on the
|
||||
* server-computed `presaleClosesAt` (event override or site default), so the
|
||||
* page and the booking API agree on the cutoff.
|
||||
*/
|
||||
export function isPresaleClosed(
|
||||
event: { presaleClosesAt?: string | null },
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
if (!event.presaleClosesAt) return false;
|
||||
return parseDate(event.presaleClosesAt).getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
export type DurationUnit = 'minutes' | 'hours' | 'days';
|
||||
|
||||
const MINUTES_PER_UNIT: Record<DurationUnit, number> = {
|
||||
minutes: 1,
|
||||
hours: 60,
|
||||
days: 1440,
|
||||
};
|
||||
|
||||
/** Split a minute count into the largest unit that divides it evenly. */
|
||||
export function minutesToDuration(minutes: number): { value: number; unit: DurationUnit } {
|
||||
const safe = Number.isFinite(minutes) && minutes >= 0 ? Math.floor(minutes) : 0;
|
||||
if (safe > 0 && safe % MINUTES_PER_UNIT.days === 0) return { value: safe / MINUTES_PER_UNIT.days, unit: 'days' };
|
||||
if (safe > 0 && safe % MINUTES_PER_UNIT.hours === 0) return { value: safe / MINUTES_PER_UNIT.hours, unit: 'hours' };
|
||||
return { value: safe, unit: 'minutes' };
|
||||
}
|
||||
|
||||
const DURATION_WORDS: Record<'en' | 'es', Record<DurationUnit, [string, string]>> = {
|
||||
en: { minutes: ['minute', 'minutes'], hours: ['hour', 'hours'], days: ['day', 'days'] },
|
||||
es: { minutes: ['minuto', 'minutos'], hours: ['hora', 'horas'], days: ['día', 'días'] },
|
||||
};
|
||||
|
||||
/** "30 minutes", "1 hour", "2 días" — largest unit that divides the minutes evenly. */
|
||||
export function formatDurationWords(minutes: number, locale: 'en' | 'es' = 'en'): string {
|
||||
const { value, unit } = minutesToDuration(minutes);
|
||||
const [one, many] = DURATION_WORDS[locale][unit];
|
||||
return `${value} ${value === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
export function durationToMinutes(value: number, unit: DurationUnit): number {
|
||||
const safe = Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
return Math.round(safe * MINUTES_PER_UNIT[unit]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user