4 Commits
Author SHA1 Message Date
MichilisandCursor 0b7f0a474e Show pre-sale close time as a duration before the event starts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 15:40:30 +00:00
MichilisandClaude Fable 5.1 a70333f5e4 Add the staff permissions audit report.
A role-by-role audit of who can do what across the backend API, the
admin frontend, Better Auth, the photo API and the deployment config.
It records the effective permission matrix for admin, organizer, staff
and marketing, where the frontend drifts from what the API actually
allows, and a prioritised remediation plan.

Committed as a reference for that remediation work; the findings in
section 11 describe the current state, not fixes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 21:16:21 +00:00
MichilisandClaude Fable 5.1 9cc330030b Show a full-screen result after every door action.
A 900ms row flash and a small bottom toast were too easy to miss at a
loud, dark door: staff could not tell at a glance whether the person in
front of them was in or not. Every action now ends on a screen that is
entirely green (checked in), red (already in, not found, write failed)
or amber (payment due), with the name and the detail staff need to
relay -- the check-in time, who did it, or the amount to collect.

It never holds the queue. Tapping anywhere closes it, it closes itself
after four seconds (six for the red ones, which carry a time or an
amount to read out), and a thin bar counts that down so the screen
vanishing never surprises anyone. Undo and Close are also explicit
buttons for staff who want them.

The undo affordance survives the close: the bottom Undo toast now
appears *after* the screen, for whatever remains of the ten-second
window, rather than alongside it -- react-hot-toast renders above the
overlay and would have floated a second Undo over the first. A write
that fails while the green screen is still up turns it red in place;
one that fails after the toast is up dismisses that toast, so staff are
never offered to undo something that never happened.

The old scanner's safe-area-top, pb-safe and animate-in classes were
undefined in this Tailwind config and silently ignored; the new screen
uses env(safe-area-inset-*) and a real transition instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 21:01:22 +00:00
MichilisandClaude Fable 5.1 59acc32a46 Close pre-sale registration a configurable time before events start.
Events gain nullable presale_closure_enabled / presale_close_minutes_before
overrides; null inherits the new site_settings defaults (enabled, 120 min).
A shared resolver computes the effective cutoff, which the public event API
exposes as presaleClosesAt and the public booking endpoint enforces. Door and
admin ticket creation are not gated.

Admin: toggle + duration picker in the event modal (pre-filled from the site
default) and a matching default card on Settings › General. Public: the event
page shows "Registration Closed" after the cutoff and the checkout page
redirects back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 20:52:09 +00:00
21 changed files with 2067 additions and 44 deletions
File diff suppressed because it is too large Load Diff
+40
View File
@@ -94,6 +94,8 @@ async function migrate() {
banner_url TEXT, banner_url TEXT,
external_booking_enabled INTEGER NOT NULL DEFAULT 0, external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url TEXT, external_booking_url TEXT,
presale_closure_enabled INTEGER,
presale_close_minutes_before INTEGER,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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`); await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
} catch (e) { /* column may already exist */ } } 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 // Add short description columns to events
try { try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`); 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_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT, maintenance_message TEXT,
maintenance_message_es 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_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id) 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)`); 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 */ } } 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 // Legal pages table for admin-editable legal content
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_pages ( CREATE TABLE IF NOT EXISTS legal_pages (
@@ -730,6 +750,8 @@ async function migrate() {
banner_url VARCHAR(500), banner_url VARCHAR(500),
external_booking_enabled INTEGER NOT NULL DEFAULT 0, external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url VARCHAR(500), external_booking_url VARCHAR(500),
presale_closure_enabled INTEGER,
presale_close_minutes_before INTEGER,
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
updated_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)`); await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
} catch (e) { /* column may already exist */ } } 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 // Add short description columns to events
try { try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`); 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_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT, maintenance_message TEXT,
maintenance_message_es 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_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id) 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)`); 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 */ } } 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 // Legal pages table for admin-editable legal content
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_pages ( CREATE TABLE IF NOT EXISTS legal_pages (
+12
View File
@@ -88,6 +88,9 @@ export const sqliteEvents = sqliteTable('events', {
bannerUrl: text('banner_url'), bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false), externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'), 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(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_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), maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
maintenanceMessage: text('maintenance_message'), maintenanceMessage: text('maintenance_message'),
maintenanceMessageEs: text('maintenance_message_es'), 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 // Metadata
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id), updatedBy: text('updated_by').references(() => sqliteUsers.id),
@@ -476,6 +482,9 @@ export const pgEvents = pgTable('events', {
bannerUrl: varchar('banner_url', { length: 500 }), bannerUrl: varchar('banner_url', { length: 500 }),
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0), externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
externalBookingUrl: varchar('external_booking_url', { length: 500 }), 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(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
@@ -761,6 +770,9 @@ export const pgSiteSettings = pgTable('site_settings', {
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0), maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
maintenanceMessage: pgText('maintenance_message'), maintenanceMessage: pgText('maintenance_message'),
maintenanceMessageEs: pgText('maintenance_message_es'), 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 // Metadata
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id), updatedBy: uuid('updated_by').references(() => pgUsers.id),
+75
View File
@@ -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);
});
});
+70
View File
@@ -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;
}
+58 -14
View File
@@ -8,6 +8,7 @@ import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calcula
import { slugify, uniqueSlug } from '../lib/slugify.js'; import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js'; import { revalidateFrontendCache } from '../lib/revalidate.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js'; import { eventSeatBreakdownQuery } from '../lib/capacity.js';
import { resolvePresaleClosure } from '../lib/presale.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -19,10 +20,13 @@ interface UserContext {
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Helper to normalize event data for API response // Helper to normalize event data for API response
// PostgreSQL decimal returns strings, booleans are stored as integers // PostgreSQL decimal returns strings, booleans are stored as integers.
function normalizeEvent(event: any) { // `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; if (!event) return event;
return { const normalized = {
...event, ...event,
// Convert price from string/decimal to clean number // Convert price from string/decimal to clean number
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price), 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), capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
// Convert boolean integers to actual booleans for frontend // Convert boolean integers to actual booleans for frontend
externalBookingEnabled: Boolean(event.externalBookingEnabled), 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), // 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) // External booking support - accept boolean or number (0/1 from DB)
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false), externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')), 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( const createEventSchema = baseEventSchema.refine(
(data) => { (data) => {
// If external booking is enabled, URL must be provided and must start with https:// // 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', message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
path: ['externalBookingUrl'], path: ['externalBookingUrl'],
} }
); ).refine(presaleRefine.check, presaleRefine.options);
const updateEventSchema = baseEventSchema.partial().refine( const updateEventSchema = baseEventSchema.partial().refine(
(data) => { (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', message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
path: ['externalBookingUrl'], path: ['externalBookingUrl'],
} }
); ).refine(presaleRefine.check, presaleRefine.options);
// Get all events (public) // Get all events (public)
eventsRouter.get('/', async (c) => { eventsRouter.get('/', async (c) => {
@@ -235,8 +269,9 @@ eventsRouter.get('/', async (c) => {
}); });
} }
const siteSettingsRow = await getSiteSettingsRow();
const eventsWithCounts = result.map((event: any) => { 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 }; const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return { return {
...normalized, ...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); const counts = await getEventSeatCounts(event.id);
return c.json({ return c.json({
event: { 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>( const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1) (db as any).select().from(siteSettings).limit(1)
); );
return settings || null;
}
function siteTimezoneOf(settings: any | null): string {
return settings?.timezone || 'America/Asuncion'; return settings?.timezone || 'America/Asuncion';
} }
@@ -320,7 +360,7 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
} }
const counts = await getEventSeatCounts(event.id); const counts = await getEventSeatCounts(event.id);
const normalized = normalizeEvent(event); const normalized = normalizeEvent(event, await getSiteSettingsRow());
return { return {
...normalized, ...normalized,
bookedCount: counts.paid, bookedCount: counts.paid,
@@ -389,7 +429,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
// If we have a valid featured event, return it // If we have a valid featured event, return it
if (featuredEvent) { if (featuredEvent) {
const counts = await getEventSeatCounts(featuredEvent.id); const counts = await getEventSeatCounts(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent); const normalized = normalizeEvent(featuredEvent, settings);
return c.json({ return c.json({
event: { event: {
...normalized, ...normalized,
@@ -417,7 +457,8 @@ 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(); const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow);
// Convert data for database compatibility // Convert data for database compatibility
const dbData = convertBooleansForDb(data); const dbData = convertBooleansForDb(data);
@@ -442,7 +483,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
revalidateFrontendCache(); revalidateFrontendCache();
// Return normalized event data // Return normalized event data
return c.json({ event: normalizeEvent(newEvent) }, 201); return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201);
}); });
// Update event (admin/organizer only) // Update event (admin/organizer only)
@@ -458,7 +499,8 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
} }
const now = getNow(); const now = getNow();
const tz = await getSiteTimezone(); const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow);
// Convert data for database compatibility // Convert data for database compatibility
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now }; const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
// Slug changes are handled explicitly below to manage aliases // 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) // Revalidate sitemap when an event is updated (status/dates may have changed)
revalidateFrontendCache(); revalidateFrontendCache();
return c.json({ event: normalizeEvent(updated) }); return c.json({ event: normalizeEvent(updated, siteSettingsRow) });
}); });
// Delete event (admin only) // Delete event (admin only)
@@ -634,6 +676,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
bannerUrl: existing.bannerUrl, bannerUrl: existing.bannerUrl,
externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1) externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
externalBookingUrl: existing.externalBookingUrl, externalBookingUrl: existing.externalBookingUrl,
presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null)
presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
+36 -5
View File
@@ -6,6 +6,7 @@ import { eq, and, gte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js'; import { revalidateFrontendCache } from '../lib/revalidate.js';
import { DEFAULT_PRESALE_CLOSURE_ENABLED, DEFAULT_PRESALE_CLOSE_MINUTES } from '../lib/presale.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -43,8 +44,26 @@ const updateSiteSettingsSchema = z.object({
maintenanceMode: z.boolean().optional(), maintenanceMode: z.boolean().optional(),
maintenanceMessage: z.string().optional().nullable(), maintenanceMessage: z.string().optional().nullable(),
maintenanceMessageEs: 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) // Get site settings (public - needed for frontend timezone)
siteSettingsRouter.get('/', async (c) => { siteSettingsRouter.get('/', async (c) => {
const settings = await dbGet( const settings = await dbGet(
@@ -69,11 +88,13 @@ siteSettingsRouter.get('/', async (c) => {
maintenanceMode: false, maintenanceMode: false,
maintenanceMessage: null, maintenanceMessage: null,
maintenanceMessageEs: 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 // Get available timezones
@@ -145,13 +166,15 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
maintenanceMode: toDbBool(data.maintenanceMode || false), maintenanceMode: toDbBool(data.maintenanceMode || false),
maintenanceMessage: data.maintenanceMessage || null, maintenanceMessage: data.maintenanceMessage || null,
maintenanceMessageEs: data.maintenanceMessageEs || null, maintenanceMessageEs: data.maintenanceMessageEs || null,
presaleClosureEnabled: toDbBool(data.presaleClosureEnabled ?? DEFAULT_PRESALE_CLOSURE_ENABLED),
presaleCloseMinutesBefore: data.presaleCloseMinutesBefore ?? DEFAULT_PRESALE_CLOSE_MINUTES,
updatedAt: now, updatedAt: now,
updatedBy: user.id, updatedBy: user.id,
}; };
await (db as any).insert(siteSettings).values(newSettings); 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 // Validate featured event if provided
@@ -174,6 +197,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
if (typeof data.maintenanceMode === 'boolean') { if (typeof data.maintenanceMode === 'boolean') {
updateData.maintenanceMode = toDbBool(data.maintenanceMode); updateData.maintenanceMode = toDbBool(data.maintenanceMode);
} }
if (typeof data.presaleClosureEnabled === 'boolean') {
updateData.presaleClosureEnabled = toDbBool(data.presaleClosureEnabled);
}
await (db as any) await (db as any)
.update(siteSettings) .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)) (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
); );
// Revalidate frontend cache if featured event changed // Revalidate frontend cache if featured event changed or the pre-sale
if (data.featuredEventId !== undefined) { // defaults changed (public event pages embed the computed cutoff).
if (
data.featuredEventId !== undefined ||
data.presaleClosureEnabled !== undefined ||
data.presaleCloseMinutesBefore !== undefined
) {
revalidateFrontendCache(); 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 // Set featured event (admin only) - convenience endpoint for event editor
+11
View File
@@ -11,6 +11,7 @@ import emailService from '../lib/email.js';
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js'; import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
import { seatHolderCountQuery } from '../lib/capacity.js'; import { seatHolderCountQuery } from '../lib/capacity.js';
import { isPresaleClosed } from '../lib/presale.js';
const ticketsRouter = new Hono(); 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); 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 // Validate the requested payment method is actually enabled for this event
// (merge global options with any event-level overrides; override wins when not null) // (merge global options with any event-level overrides; override wins when not null)
const globalPaymentOptions = await dbGet<any>( const globalPaymentOptions = await dbGet<any>(
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils';
import { isSafeExternalUrl } from '@/lib/safeRedirect'; import { isSafeExternalUrl } from '@/lib/safeRedirect';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import type { import type {
@@ -108,6 +108,14 @@ export default function BookingPage() {
return; 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 // Server-authoritative availability — same formula the booking API
// enforces, so a sold-out event is caught here, not at submit time. // enforces, so a sold-out event is caught here, not at submit time.
if (isEventSoldOut(eventRes.event)) { if (isEventSoldOut(eventRes.event)) {
@@ -364,11 +372,18 @@ export default function BookingPage() {
toast.success(t('booking.success.message')); toast.success(t('booking.success.message'));
} }
} catch (error: any) { } 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')); toast.error(error.message || t('booking.form.errors.bookingFailed'));
// Capacity race on the last seats: refresh availability so the page // Capacity race on the last seats: refresh availability so the page
// reflects reality (sold-out block / lower quantity cap) instead of the // reflects reality (sold-out block / lower quantity cap) instead of the
// stale counts loaded when the form was opened. // stale counts loaded when the form was opened.
const message = String(error?.message || '');
if (/sold out|seats available/i.test(message)) { if (/sold out|seats available/i.test(message)) {
try { try {
const { event: freshEvent } = await eventsApi.getById(params.eventId as string); 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 Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; 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 Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons'; import ShareButtons from '@/components/ShareButtons';
@@ -69,7 +69,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const isCancelled = event.status === 'cancelled'; const isCancelled = event.status === 'cancelled';
// Only calculate isPastEvent after mount to avoid hydration mismatch // Only calculate isPastEvent after mount to avoid hydration mismatch
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; 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 // Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => ( const BookingCardContent = () => (
@@ -143,11 +149,23 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<Button className="w-full" size="lg" disabled> <Button className="w-full" size="lg" disabled>
{isPastEvent {isPastEvent
? t('events.details.eventEnded') ? t('events.details.eventEnded')
: presaleClosed
? t('events.details.registrationClosed')
: isSoldOut : isSoldOut
? t('events.details.soldOut') ? t('events.details.soldOut')
: t('events.details.cancelled')} : t('events.details.cancelled')}
</Button> </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 && ( {!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500"> <p className="mt-4 text-center text-sm text-gray-500">
@@ -25,6 +25,7 @@ interface Event {
bannerUrl?: string; bannerUrl?: string;
availableSeats?: number; availableSeats?: number;
bookedCount?: number; bookedCount?: number;
presaleClosesAt?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -125,6 +126,7 @@ function generateEventJsonLd(event: Event) {
: 'https://schema.org/SoldOut', : 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.slug}`, url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(), validFrom: new Date().toISOString(),
...(event.presaleClosesAt ? { validThrough: event.presaleClosesAt } : {}),
}, },
image: event.bannerUrl image: event.bannerUrl
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${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 Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker'; import MediaPicker from '@/components/MediaPicker';
import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
interface EventFormData { interface EventFormData {
title: string; title: string;
@@ -30,14 +31,26 @@ interface EventFormData {
bannerUrl: string; bannerUrl: string;
externalBookingEnabled: boolean; externalBookingEnabled: boolean;
externalBookingUrl: string; 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 = { const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '', title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '', shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft', price: 0, currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
}; };
function isoToLocalDatetime(isoString: string): string { function isoToLocalDatetime(isoString: string): string {
@@ -89,6 +102,30 @@ export default function EventFormModal({
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]); const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [settingFeatured, setSettingFeatured] = 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -104,6 +141,8 @@ export default function EventFormModal({
status: event.status, bannerUrl: event.bannerUrl || '', status: event.status, bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false, externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '', externalBookingUrl: event.externalBookingUrl || '',
presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled,
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore,
}); });
loadSlugAliases(event.id); loadSlugAliases(event.id);
} else { } else {
@@ -161,6 +200,14 @@ export default function EventFormModal({
setSaving(false); setSaving(false);
return; 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> = { const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined, title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined,
@@ -172,6 +219,9 @@ export default function EventFormModal({
status: formData.status, bannerUrl: formData.bannerUrl || undefined, status: formData.status, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled, externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, 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) { if (event) {
// Only send slug when editing so creates still auto-generate from title // Only send slug when editing so creates still auto-generate from title
@@ -346,6 +396,35 @@ export default function EventFormModal({
)} )}
</div> </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} <MediaPicker value={formData.bannerUrl}
onChange={(url) => setFormData({ ...formData, bannerUrl: url })} onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
relatedId={event?.id} relatedType="event" /> 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'; entry: 'scan' | 'search' | 'walkin';
method: DoorPaymentMethod | null; method: DoorPaymentMethod | null;
amount: number; amount: number;
/** Epoch ms the action was fired; the undo window is measured from here. */
startedAt: number;
undone: boolean; undone: boolean;
failed: boolean; failed: boolean;
} }
+81 -18
View File
@@ -31,6 +31,7 @@ import { QRScannerOverlay } from './_components/QRScannerOverlay';
import { AttendeeRow } from './_components/AttendeeRow'; import { AttendeeRow } from './_components/AttendeeRow';
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow'; import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
import { SessionSheet, type SessionEntry } from './_components/SessionSheet'; import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Door check-in screen // Door check-in screen
@@ -90,6 +91,14 @@ export default function AdminDoorPage() {
const [walkInDraft, setWalkInDraft] = useState<WalkInDraft>(emptyWalkIn()); const [walkInDraft, setWalkInDraft] = useState<WalkInDraft>(emptyWalkIn());
const [scannerOpen, setScannerOpen] = useState(false); const [scannerOpen, setScannerOpen] = useState(false);
const [sessionOpen, setSessionOpen] = 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 ── // ── Session bookkeeping ──
const [sessionEntries, setSessionEntries] = useState<SessionEntry[]>([]); const [sessionEntries, setSessionEntries] = useState<SessionEntry[]>([]);
@@ -256,8 +265,8 @@ export default function AdminDoorPage() {
); );
const showUndoToast = useCallback( const showUndoToast = useCallback(
(message: string, entry: SessionEntry) => { (message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => {
toast.custom( const id = toast.custom(
(t) => ( (t) => (
<div <div
className={clsx( className={clsx(
@@ -284,12 +293,45 @@ export default function AdminDoorPage() {
</button> </button>
</div> </div>
), ),
{ duration: UNDO_WINDOW_MS, position: 'bottom-center' }, { duration: durationMs, position: 'bottom-center' },
); );
undoToastIdsRef.current.set(entry.idempotencyKey, id);
}, },
[handleUndo], [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 ────────────────────────────────────── // ─── The one write path ──────────────────────────────────────
// Every completed action on this screen — scan, tap, collect, walk-in — flows // 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 // 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, entry: opts.entry,
method: opts.payment?.method ?? null, method: opts.payment?.method ?? null,
amount: opts.payment?.amount ?? 0, amount: opts.payment?.amount ?? 0,
startedAt: now.getTime(),
undone: false, undone: false,
failed: false, failed: false,
}; };
@@ -355,7 +398,12 @@ export default function AdminDoorPage() {
playSuccessSound(); playSuccessSound();
const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : ''; 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(''); setQuery('');
setExpandedId(null); setExpandedId(null);
@@ -395,6 +443,20 @@ export default function AdminDoorPage() {
if (previousRow) upsertAttendee(previousRow); if (previousRow) upsertAttendee(previousRow);
playErrorSound(); playErrorSound();
vibrate([100, 50, 100]); 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(), { toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), {
duration: 12000, duration: 12000,
position: 'bottom-center', position: 'bottom-center',
@@ -404,7 +466,7 @@ export default function AdminDoorPage() {
setBusyId((current) => (current === opts.busyKey ? null : current)); setBusyId((current) => (current === opts.busyKey ? null : current));
} }
}, },
[data?.attendees, patchAttendee, upsertAttendee, showUndoToast, focusInput, loadAttendees], [data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees],
); );
// ─── Row interactions ──────────────────────────────────────── // ─── Row interactions ────────────────────────────────────────
@@ -488,19 +550,17 @@ export default function AdminDoorPage() {
if (!attendee) { if (!attendee) {
playErrorSound(); playErrorSound();
vibrate([100, 50, 100]); vibrate([100, 50, 100]);
toast.error('Ticket not found for this event', { position: 'bottom-center', duration: 6000 }); showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' });
focusInput();
return; return;
} }
if (attendee.checkedIn) { if (attendee.checkedIn) {
playErrorSound(); playErrorSound();
vibrate([100, 50, 100]); vibrate([100, 50, 100]);
toast(`${attendee.fullName} already checked in${attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''}`, { const at = attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : '';
icon: '', const by = attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : '';
position: 'bottom-center', showResult({ kind: 'already_in', name: attendee.fullName, detail: `Already checked in${at}${by}` });
duration: 6000, // Their row sits open underneath, so closing lands on the same person.
});
setQuery(attendee.fullName); setQuery(attendee.fullName);
setExpandedId(attendee.ticketId); setExpandedId(attendee.ticketId);
return; return;
@@ -508,13 +568,14 @@ export default function AdminDoorPage() {
if (attendee.paymentStatus === 'unpaid') { if (attendee.paymentStatus === 'unpaid') {
// Checking an unpaid ticket in silently would walk the money out the // 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(); playErrorSound();
vibrate(200); vibrate(200);
toast(`Collect ${formatCurrency(attendee.amountDue || price, currency)} from ${attendee.fullName}`, { showResult({
icon: '💰', kind: 'unpaid',
position: 'bottom-center', name: attendee.fullName,
duration: 8000, detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`,
}); });
setQuery(attendee.fullName); setQuery(attendee.fullName);
setExpandedId(attendee.ticketId); setExpandedId(attendee.ticketId);
@@ -529,7 +590,7 @@ export default function AdminDoorPage() {
busyKey: attendee.ticketId, busyKey: attendee.ticketId,
}); });
}, },
[data?.attendees, runAction, focusInput, price, currency], [data?.attendees, runAction, showResult, price, currency],
); );
// ─── Session summary ───────────────────────────────────────── // ─── Session summary ─────────────────────────────────────────
@@ -732,6 +793,8 @@ export default function AdminDoorPage() {
/> />
)} )}
{result && <ResultScreen result={result} onClose={closeResult} onUndo={undoFromResult} />}
{sessionOpen && ( {sessionOpen && (
<SessionSheet <SessionSheet
entries={sessionEntries} entries={sessionEntries}
+67
View File
@@ -8,8 +8,10 @@ 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';
import DurationInput from '@/components/admin/DurationInput';
import { import {
Cog6ToothIcon, Cog6ToothIcon,
TicketIcon,
GlobeAltIcon, GlobeAltIcon,
ClockIcon, ClockIcon,
EnvelopeIcon, EnvelopeIcon,
@@ -47,6 +49,8 @@ export default function AdminSettingsPage() {
maintenanceMode: false, maintenanceMode: false,
maintenanceMessage: null, maintenanceMessage: null,
maintenanceMessageEs: null, maintenanceMessageEs: null,
presaleClosureEnabled: true,
presaleCloseMinutesBefore: 120,
}); });
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({ const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
@@ -508,6 +512,69 @@ export default function AdminSettingsPage() {
</div> </div>
</Card> </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 */} {/* Maintenance Mode */}
<Card> <Card>
<div className="p-6"> <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>
);
}
+4 -1
View File
@@ -88,7 +88,10 @@
"spotsLeft": "spots left", "spotsLeft": "spots left",
"soldOut": "Sold Out", "soldOut": "Sold Out",
"cancelled": "Cancelled", "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": { "booking": {
"join": "Join Event", "join": "Join Event",
+4 -1
View File
@@ -88,7 +88,10 @@
"spotsLeft": "lugares disponibles", "spotsLeft": "lugares disponibles",
"soldOut": "Agotado", "soldOut": "Agotado",
"cancelled": "Cancelado", "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": { "booking": {
"join": "Unirse al Evento", "join": "Unirse al Evento",
+5
View File
@@ -18,6 +18,9 @@ export interface Event {
bannerUrl?: string; bannerUrl?: string;
externalBookingEnabled?: boolean; externalBookingEnabled?: boolean;
externalBookingUrl?: string; 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) bookedCount?: number; // paid seats (confirmed + checked_in)
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats) claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
@@ -484,6 +487,8 @@ export interface SiteSettings {
maintenanceMode: boolean; maintenanceMode: boolean;
maintenanceMessage?: string | null; maintenanceMessage?: string | null;
maintenanceMessageEs?: string | null; maintenanceMessageEs?: string | null;
presaleClosureEnabled: boolean;
presaleCloseMinutesBefore: number;
updatedAt?: string; updatedAt?: string;
updatedBy?: string; updatedBy?: string;
} }
+46
View File
@@ -235,3 +235,49 @@ export function isEventSoldOut(event: {
}): boolean { }): boolean {
return eventSpotsLeft(event) <= 0; 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]);
}