Compare commits
4
Commits
backup-prod16
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7f0a474e | ||
|
|
a70333f5e4 | ||
|
|
9cc330030b | ||
|
|
59acc32a46 |
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
resolvePresaleClosure,
|
||||||
|
isPresaleClosed,
|
||||||
|
DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||||
|
} from './presale.js';
|
||||||
|
|
||||||
|
const START = '2030-01-01T20:00:00.000Z';
|
||||||
|
const startMs = new Date(START).getTime();
|
||||||
|
const minutes = (n: number) => n * 60_000;
|
||||||
|
|
||||||
|
describe('resolvePresaleClosure', () => {
|
||||||
|
it('falls back to the built-in defaults when neither event nor settings specify anything', () => {
|
||||||
|
const r = resolvePresaleClosure({ startDatetime: START }, null);
|
||||||
|
expect(r.enabled).toBe(true);
|
||||||
|
expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inherits from site settings when the event has null overrides', () => {
|
||||||
|
const r = resolvePresaleClosure(
|
||||||
|
{ startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null },
|
||||||
|
{ presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 }
|
||||||
|
);
|
||||||
|
expect(r.minutesBefore).toBe(30);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(30));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the event override the site settings', () => {
|
||||||
|
const r = resolvePresaleClosure(
|
||||||
|
{ startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 },
|
||||||
|
{ presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 }
|
||||||
|
);
|
||||||
|
expect(r.enabled).toBe(true);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns closesAt null when closure is disabled (site-wide or per event)', () => {
|
||||||
|
expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts PostgreSQL 0/1 integers and string minutes', () => {
|
||||||
|
const r = resolvePresaleClosure(
|
||||||
|
{ startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' },
|
||||||
|
{ presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 }
|
||||||
|
);
|
||||||
|
expect(r.enabled).toBe(true);
|
||||||
|
expect(r.minutesBefore).toBe(45);
|
||||||
|
const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 });
|
||||||
|
expect(off.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes at the event start when minutes is 0', () => {
|
||||||
|
const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPresaleClosed', () => {
|
||||||
|
const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 };
|
||||||
|
|
||||||
|
it('is open before the cutoff and closed from the cutoff onwards', () => {
|
||||||
|
const cutoff = startMs - minutes(60);
|
||||||
|
expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false);
|
||||||
|
expect(isPresaleClosed(event, null, cutoff)).toBe(true);
|
||||||
|
expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never closes when closure is disabled', () => {
|
||||||
|
expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Pre-sale closure: online registration for an event closes a configurable
|
||||||
|
* number of minutes before the event starts.
|
||||||
|
*
|
||||||
|
* Each event may override the site-wide default; a `null`/`undefined` value on
|
||||||
|
* the event means "inherit from site_settings". Values arrive as booleans on
|
||||||
|
* SQLite and as 0/1 integers on PostgreSQL, so both are accepted here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_PRESALE_CLOSURE_ENABLED = true;
|
||||||
|
export const DEFAULT_PRESALE_CLOSE_MINUTES = 120;
|
||||||
|
|
||||||
|
export interface PresaleEventLike {
|
||||||
|
startDatetime: string | Date;
|
||||||
|
presaleClosureEnabled?: boolean | number | null;
|
||||||
|
presaleCloseMinutesBefore?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresaleSettingsLike {
|
||||||
|
presaleClosureEnabled?: boolean | number | null;
|
||||||
|
presaleCloseMinutesBefore?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedPresaleClosure {
|
||||||
|
enabled: boolean;
|
||||||
|
minutesBefore: number;
|
||||||
|
/** When online registration closes, or null when closure is disabled. */
|
||||||
|
closesAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(value: boolean | number | null | undefined): boolean | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return typeof value === 'number' ? value !== 0 : Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMinutes(value: number | string | null | undefined): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const n = typeof value === 'string' ? parseInt(value, 10) : value;
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePresaleClosure(
|
||||||
|
event: PresaleEventLike,
|
||||||
|
settings?: PresaleSettingsLike | null
|
||||||
|
): ResolvedPresaleClosure {
|
||||||
|
const enabled =
|
||||||
|
toBool(event.presaleClosureEnabled) ??
|
||||||
|
toBool(settings?.presaleClosureEnabled) ??
|
||||||
|
DEFAULT_PRESALE_CLOSURE_ENABLED;
|
||||||
|
const minutesBefore =
|
||||||
|
toMinutes(event.presaleCloseMinutesBefore) ??
|
||||||
|
toMinutes(settings?.presaleCloseMinutesBefore) ??
|
||||||
|
DEFAULT_PRESALE_CLOSE_MINUTES;
|
||||||
|
|
||||||
|
if (!enabled) return { enabled, minutesBefore, closesAt: null };
|
||||||
|
|
||||||
|
const startMs = new Date(event.startDatetime).getTime();
|
||||||
|
if (!Number.isFinite(startMs)) return { enabled, minutesBefore, closesAt: null };
|
||||||
|
|
||||||
|
return { enabled, minutesBefore, closesAt: new Date(startMs - minutesBefore * 60_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPresaleClosed(
|
||||||
|
event: PresaleEventLike,
|
||||||
|
settings?: PresaleSettingsLike | null,
|
||||||
|
nowMs: number = Date.now()
|
||||||
|
): boolean {
|
||||||
|
const { closesAt } = resolvePresaleClosure(event, settings);
|
||||||
|
return closesAt !== null && closesAt.getTime() <= nowMs;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calcula
|
|||||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user