35 Commits
Author SHA1 Message Date
Michilis 183a2c99ed Merge pull request 'Restrict whole-event door takings to admin and organizer.' (#35) from dev into main
Reviewed-on: #35
2026-08-23 06:38:57 +00:00
Michilis 279cd19308 Merge pull request 'Fix the backend build against hono's narrowed route param type.' (#34) from dev into main
Reviewed-on: #34
2026-08-23 06:16:27 +00:00
Michilis de791fd17d Merge pull request 'Dev' (#33) from dev into main
Reviewed-on: #33
2026-08-23 06:04:26 +00:00
Michilis d06f80e572 Merge pull request 'Add mobile save sheet with same-origin photo downloads.' (#32) from dev into main
Reviewed-on: #32
2026-08-06 20:20:15 +00:00
Michilis e0f0b2f1e9 Merge pull request 'Dev' (#31) from dev into main
Reviewed-on: #31
2026-08-05 23:18:50 +00:00
Michilis 82857b5aa8 Merge pull request 'Fix post-login redirect when the session cookie is off-origin' (#30) from dev into main
Reviewed-on: #30
2026-07-29 20:46:11 +00:00
Michilis 5b478c327d Merge pull request 'Dev' (#29) from dev into main
Reviewed-on: #29
2026-07-29 19:08:28 +00:00
Michilis 6ec8b8400f Merge pull request 'dev' (#28) from dev into main
Reviewed-on: #28
2026-07-26 05:38:23 +00:00
Michilis 19461098a0 Merge pull request 'Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.' (#27) from tpago-fix into main
Reviewed-on: #27
2026-07-20 03:55:01 +00:00
Michilis 74dcc8e5ac Merge pull request 'Normalize RUC formatting to base-checkdigit across booking and admin views.' (#26) from RUC-fix into main
Reviewed-on: #26
2026-07-13 03:48:13 +00:00
Michilis bd1043a3f4 Merge pull request 'speed' (#25) from speed into main
Reviewed-on: #25
2026-07-13 01:18:23 +00:00
Michilis 107cd9753e Merge pull request 'Redesign user dashboard with overview tab and i18n payment copy.' (#24) from dashboard-update into main
Reviewed-on: #24
2026-06-29 17:00:15 +00:00
Michilis 194604004e Merge pull request 'security-update' (#23) from security-update into main
Reviewed-on: #23
2026-06-29 06:47:19 +00:00
Michilis bb920ce6f0 Merge pull request 'Add per-quantity TPago payment links for multi-ticket checkout.' (#22) from tpago-update into main
Reviewed-on: #22
2026-06-19 00:24:00 +00:00
Michilis c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis 1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
Michilis 91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
Michilis a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
Michilis f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis 15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis 194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
Michilis 833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
Michilis ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
Michilis 3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
Michilis 8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
21 changed files with 44 additions and 2067 deletions
File diff suppressed because it is too large Load Diff
-40
View File
@@ -94,8 +94,6 @@ 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
)
@@ -109,14 +107,6 @@ 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`);
@@ -520,8 +510,6 @@ 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)
)
@@ -532,14 +520,6 @@ 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 (
@@ -750,8 +730,6 @@ 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
)
@@ -765,14 +743,6 @@ 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)`);
@@ -1134,8 +1104,6 @@ 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)
)
@@ -1146,14 +1114,6 @@ 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 (
-12
View File
@@ -88,9 +88,6 @@ 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(),
});
@@ -390,9 +387,6 @@ 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),
@@ -482,9 +476,6 @@ 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(),
});
@@ -770,9 +761,6 @@ 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),
-75
View File
@@ -1,75 +0,0 @@
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
@@ -1,70 +0,0 @@
/**
* 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;
}
+14 -58
View File
@@ -8,7 +8,6 @@ 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;
@@ -20,13 +19,10 @@ 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.
// `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) {
// PostgreSQL decimal returns strings, booleans are stored as integers
function normalizeEvent(event: any) {
if (!event) return event;
const normalized = {
return {
...event,
// Convert price from string/decimal to clean number
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
@@ -34,15 +30,7 @@ function normalizeEvent(event: any, settings?: 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),
@@ -150,30 +138,8 @@ 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://
@@ -186,7 +152,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) => {
@@ -200,7 +166,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) => {
@@ -269,9 +235,8 @@ eventsRouter.get('/', async (c) => {
});
}
const siteSettingsRow = await getSiteSettingsRow();
const eventsWithCounts = result.map((event: any) => {
const normalized = normalizeEvent(event, siteSettingsRow);
const normalized = normalizeEvent(event);
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...normalized,
@@ -304,7 +269,7 @@ eventsRouter.get('/:id', async (c) => {
}
}
const normalized = normalizeEvent(event, await getSiteSettingsRow());
const normalized = normalizeEvent(event);
const counts = await getEventSeatCounts(event.id);
return c.json({
event: {
@@ -316,15 +281,10 @@ eventsRouter.get('/:id', async (c) => {
});
});
// Single site_settings row (or null when none has been created yet)
async function getSiteSettingsRow(): Promise<any | null> {
async function getSiteTimezone(): Promise<string> {
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';
}
@@ -360,7 +320,7 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
}
const counts = await getEventSeatCounts(event.id);
const normalized = normalizeEvent(event, await getSiteSettingsRow());
const normalized = normalizeEvent(event);
return {
...normalized,
bookedCount: counts.paid,
@@ -429,7 +389,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, settings);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
@@ -457,8 +417,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
const user = c.get('user');
const now = getNow();
const id = generateId();
const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow);
const tz = await getSiteTimezone();
// Convert data for database compatibility
const dbData = convertBooleansForDb(data);
@@ -483,7 +442,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
revalidateFrontendCache();
// Return normalized event data
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201);
return c.json({ event: normalizeEvent(newEvent) }, 201);
});
// Update event (admin/organizer only)
@@ -499,8 +458,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
}
const now = getNow();
const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow);
const tz = await getSiteTimezone();
// Convert data for database compatibility
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
// Slug changes are handled explicitly below to manage aliases
@@ -559,7 +517,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, siteSettingsRow) });
return c.json({ event: normalizeEvent(updated) });
});
// Delete event (admin only)
@@ -676,8 +634,6 @@ 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,
};
+5 -36
View File
@@ -6,7 +6,6 @@ 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;
@@ -44,26 +43,8 @@ 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(
@@ -88,13 +69,11 @@ siteSettingsRouter.get('/', async (c) => {
maintenanceMode: false,
maintenanceMessage: null,
maintenanceMessageEs: null,
presaleClosureEnabled: DEFAULT_PRESALE_CLOSURE_ENABLED,
presaleCloseMinutesBefore: DEFAULT_PRESALE_CLOSE_MINUTES,
},
});
}
return c.json({ settings: normalizeSettings(settings) });
return c.json({ settings });
});
// Get available timezones
@@ -166,15 +145,13 @@ 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: normalizeSettings(newSettings), message: 'Settings created successfully' }, 201);
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
}
// Validate featured event if provided
@@ -197,9 +174,6 @@ 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)
@@ -210,17 +184,12 @@ 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 or the pre-sale
// defaults changed (public event pages embed the computed cutoff).
if (
data.featuredEventId !== undefined ||
data.presaleClosureEnabled !== undefined ||
data.presaleCloseMinutesBefore !== undefined
) {
// Revalidate frontend cache if featured event changed
if (data.featuredEventId !== undefined) {
revalidateFrontendCache();
}
return c.json({ settings: normalizeSettings(updated), message: 'Settings updated successfully' });
return c.json({ settings: updated, message: 'Settings updated successfully' });
});
// Set featured event (admin only) - convenience endpoint for event editor
-11
View File
@@ -11,7 +11,6 @@ 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();
@@ -112,16 +111,6 @@ 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, isPresaleClosed } from '@/lib/utils';
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import { isSafeExternalUrl } from '@/lib/safeRedirect';
import toast from 'react-hot-toast';
import type {
@@ -108,14 +108,6 @@ 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)) {
@@ -372,18 +364,11 @@ 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, isPresaleClosed, parseDate, formatDurationWords } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
@@ -69,13 +69,7 @@ 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;
// 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;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
// Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => (
@@ -149,23 +143,11 @@ 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">
@@ -25,7 +25,6 @@ interface Event {
bannerUrl?: string;
availableSeats?: number;
bookedCount?: number;
presaleClosesAt?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -126,7 +125,6 @@ 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,11 +6,10 @@ 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, formatDurationWords } from '@/lib/utils';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
interface EventFormData {
title: string;
@@ -31,26 +30,14 @@ 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 {
@@ -102,30 +89,6 @@ 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;
@@ -141,8 +104,6 @@ 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 {
@@ -200,14 +161,6 @@ 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,
@@ -219,9 +172,6 @@ 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
@@ -396,35 +346,6 @@ 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" />
@@ -1,134 +0,0 @@
'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,8 +21,6 @@ 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;
}
+18 -81
View File
@@ -31,7 +31,6 @@ 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
@@ -91,14 +90,6 @@ 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[]>([]);
@@ -265,8 +256,8 @@ export default function AdminDoorPage() {
);
const showUndoToast = useCallback(
(message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => {
const id = toast.custom(
(message: string, entry: SessionEntry) => {
toast.custom(
(t) => (
<div
className={clsx(
@@ -293,45 +284,12 @@ export default function AdminDoorPage() {
</button>
</div>
),
{ duration: durationMs, position: 'bottom-center' },
{ duration: UNDO_WINDOW_MS, 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
@@ -363,7 +321,6 @@ export default function AdminDoorPage() {
entry: opts.entry,
method: opts.payment?.method ?? null,
amount: opts.payment?.amount ?? 0,
startedAt: now.getTime(),
undone: false,
failed: false,
};
@@ -398,12 +355,7 @@ export default function AdminDoorPage() {
playSuccessSound();
const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : '';
showResult({
kind: 'success',
name: opts.displayName,
detail: `Checked in ${clockTime(now)}${paidSuffix}`,
entry: sessionEntry,
});
showUndoToast(`${opts.displayName} checked in${paidSuffix}`, sessionEntry);
setQuery('');
setExpandedId(null);
@@ -443,20 +395,6 @@ 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',
@@ -466,7 +404,7 @@ export default function AdminDoorPage() {
setBusyId((current) => (current === opts.busyKey ? null : current));
}
},
[data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees],
[data?.attendees, patchAttendee, upsertAttendee, showUndoToast, focusInput, loadAttendees],
);
// ─── Row interactions ────────────────────────────────────────
@@ -550,17 +488,19 @@ export default function AdminDoorPage() {
if (!attendee) {
playErrorSound();
vibrate([100, 50, 100]);
showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' });
toast.error('Ticket not found for this event', { position: 'bottom-center', duration: 6000 });
focusInput();
return;
}
if (attendee.checkedIn) {
playErrorSound();
vibrate([100, 50, 100]);
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.
toast(`${attendee.fullName} already checked in${attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''}`, {
icon: '',
position: 'bottom-center',
duration: 6000,
});
setQuery(attendee.fullName);
setExpandedId(attendee.ticketId);
return;
@@ -568,14 +508,13 @@ 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: "Collect" closes
// the screen straight onto the payment buttons.
// door. Surface them with the tenders open instead.
playErrorSound();
vibrate(200);
showResult({
kind: 'unpaid',
name: attendee.fullName,
detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`,
toast(`Collect ${formatCurrency(attendee.amountDue || price, currency)} from ${attendee.fullName}`, {
icon: '💰',
position: 'bottom-center',
duration: 8000,
});
setQuery(attendee.fullName);
setExpandedId(attendee.ticketId);
@@ -590,7 +529,7 @@ export default function AdminDoorPage() {
busyKey: attendee.ticketId,
});
},
[data?.attendees, runAction, showResult, price, currency],
[data?.attendees, runAction, focusInput, price, currency],
);
// ─── Session summary ─────────────────────────────────────────
@@ -793,8 +732,6 @@ export default function AdminDoorPage() {
/>
)}
{result && <ResultScreen result={result} onClose={closeResult} onUndo={undoFromResult} />}
{sessionOpen && (
<SessionSheet
entries={sessionEntries}
-67
View File
@@ -8,10 +8,8 @@ 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,
@@ -49,8 +47,6 @@ export default function AdminSettingsPage() {
maintenanceMode: false,
maintenanceMessage: null,
maintenanceMessageEs: null,
presaleClosureEnabled: true,
presaleCloseMinutesBefore: 120,
});
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
@@ -512,69 +508,6 @@ 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">
@@ -1,91 +0,0 @@
'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>
);
}
+1 -4
View File
@@ -88,10 +88,7 @@
"spotsLeft": "spots left",
"soldOut": "Sold Out",
"cancelled": "Cancelled",
"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."
"eventEnded": "Event Ended"
},
"booking": {
"join": "Join Event",
+1 -4
View File
@@ -88,10 +88,7 @@
"spotsLeft": "lugares disponibles",
"soldOut": "Agotado",
"cancelled": "Cancelado",
"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."
"eventEnded": "Evento Finalizado"
},
"booking": {
"join": "Unirse al Evento",
-5
View File
@@ -18,9 +18,6 @@ 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
@@ -487,8 +484,6 @@ export interface SiteSettings {
maintenanceMode: boolean;
maintenanceMessage?: string | null;
maintenanceMessageEs?: string | null;
presaleClosureEnabled: boolean;
presaleCloseMinutesBefore: number;
updatedAt?: string;
updatedBy?: string;
}
-46
View File
@@ -235,49 +235,3 @@ 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]);
}