Close pre-sale registration a configurable time before events start.

Events gain nullable presale_closure_enabled / presale_close_minutes_before
overrides; null inherits the new site_settings defaults (enabled, 120 min).
A shared resolver computes the effective cutoff, which the public event API
exposes as presaleClosesAt and the public booking endpoint enforces. Door and
admin ticket creation are not gated.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-14 20:52:09 +00:00
co-authored by Claude Fable 5.1
parent 745af4184f
commit 59acc32a46
17 changed files with 624 additions and 26 deletions
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils';
import { isSafeExternalUrl } from '@/lib/safeRedirect';
import toast from 'react-hot-toast';
import type {
@@ -108,6 +108,14 @@ export default function BookingPage() {
return;
}
// Pre-sale closure: the booking API rejects after the cutoff, so
// bounce back to the event page instead of showing a dead form.
if (isPresaleClosed(eventRes.event)) {
toast.error(t('events.details.registrationClosed'));
router.push(`/events/${eventRes.event.slug}`);
return;
}
// Server-authoritative availability — same formula the booking API
// enforces, so a sold-out event is caught here, not at submit time.
if (isEventSoldOut(eventRes.event)) {
@@ -364,11 +372,18 @@ export default function BookingPage() {
toast.success(t('booking.success.message'));
}
} catch (error: any) {
const message = String(error?.message || '');
// Pre-sale closed while the form was open: send the user back to the
// event page, which now shows the "Registration Closed" state.
if (/registration .*closed/i.test(message)) {
toast.error(t('events.details.registrationClosed'));
if (event?.slug) router.push(`/events/${event.slug}`);
return;
}
toast.error(error.message || t('booking.form.errors.bookingFailed'));
// Capacity race on the last seats: refresh availability so the page
// reflects reality (sold-out block / lower quantity cap) instead of the
// stale counts loaded when the form was opened.
const message = String(error?.message || '');
if (/sold out|seats available/i.test(message)) {
try {
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
@@ -5,7 +5,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
@@ -69,7 +69,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const isCancelled = event.status === 'cancelled';
// Only calculate isPastEvent after mount to avoid hydration mismatch
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
// Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent
const presaleClosed = mounted ? isPresaleClosed(event) : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted');
// Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => (
@@ -143,11 +145,21 @@ 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 && event.presaleClosesAt && (
<p className="mt-3 text-center text-xs text-gray-500">
{t('events.details.registrationClosesAt', {
date: `${formatDate(event.presaleClosesAt)} ${fmtTime(event.presaleClosesAt)}`,
})}
</p>
)}
{!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500">
@@ -25,6 +25,7 @@ interface Event {
bannerUrl?: string;
availableSeats?: number;
bookedCount?: number;
presaleClosesAt?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -125,6 +126,7 @@ function generateEventJsonLd(event: Event) {
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(),
...(event.presaleClosesAt ? { validThrough: event.presaleClosesAt } : {}),
},
image: event.bannerUrl
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
@@ -6,10 +6,11 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker';
import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { parseDate, EVENT_TIMEZONE, minutesToDuration } from '@/lib/utils';
interface EventFormData {
title: string;
@@ -30,6 +31,22 @@ 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 };
function describeDuration(minutes: number): string {
const { value, unit } = minutesToDuration(minutes);
const label = value === 1 ? unit.slice(0, -1) : unit;
return `${value} ${label}`;
}
const EMPTY_FORM: EventFormData = {
@@ -38,6 +55,8 @@ const EMPTY_FORM: EventFormData = {
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
function isoToLocalDatetime(isoString: string): string {
@@ -89,6 +108,30 @@ export default function EventFormModal({
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [saving, setSaving] = useState(false);
const [settingFeatured, setSettingFeatured] = useState(false);
const [presaleDefaults, setPresaleDefaults] = useState<PresaleDefaults>(FALLBACK_PRESALE_DEFAULTS);
useEffect(() => {
if (!open) return;
let cancelled = false;
// Site defaults fill in whatever the event hasn't overridden (null = inherit)
siteSettingsApi.get()
.then(({ settings }) => {
if (cancelled) return;
const defaults: PresaleDefaults = {
enabled: settings.presaleClosureEnabled ?? FALLBACK_PRESALE_DEFAULTS.enabled,
minutesBefore: settings.presaleCloseMinutesBefore ?? FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
setPresaleDefaults(defaults);
setFormData((prev) => ({
...prev,
presaleClosureEnabled: event?.presaleClosureEnabled ?? defaults.enabled,
presaleCloseMinutesBefore: event?.presaleCloseMinutesBefore ?? defaults.minutesBefore,
}));
})
.catch(() => { /* keep fallback defaults */ });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, event]);
useEffect(() => {
if (!open) return;
@@ -104,6 +147,8 @@ export default function EventFormModal({
status: event.status, bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '',
presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled,
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore,
});
loadSlugAliases(event.id);
} else {
@@ -161,6 +206,14 @@ export default function EventFormModal({
setSaving(false);
return;
}
if (
formData.presaleClosureEnabled &&
(!Number.isFinite(formData.presaleCloseMinutesBefore) || formData.presaleCloseMinutesBefore < 0)
) {
toast.error('Pre-sale closure time must be zero or more');
setSaving(false);
return;
}
const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
@@ -172,6 +225,9 @@ export default function EventFormModal({
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
// Saving from the modal always writes explicit values (overrides the site default)
presaleClosureEnabled: formData.presaleClosureEnabled,
presaleCloseMinutesBefore: formData.presaleCloseMinutesBefore,
};
if (event) {
// Only send slug when editing so creates still auto-generate from title
@@ -346,6 +402,35 @@ export default function EventFormModal({
)}
</div>
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">Pre-sale Closure</label>
<p className="text-xs text-gray-500">Stop online registration before the event starts</p>
</div>
<button type="button"
onClick={() => setFormData({ ...formData, presaleClosureEnabled: !formData.presaleClosureEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
formData.presaleClosureEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
}`}>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
formData.presaleClosureEnabled ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
{formData.presaleClosureEnabled && (
<DurationInput label="Close pre-sale this long before the start"
valueMinutes={formData.presaleCloseMinutesBefore}
onChange={(minutes) => setFormData({ ...formData, presaleCloseMinutesBefore: minutes })} />
)}
<p className="text-xs text-gray-500">
Site default: {presaleDefaults.enabled
? `closes ${describeDuration(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" />
+67
View File
@@ -8,8 +8,10 @@ import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import DurationInput from '@/components/admin/DurationInput';
import {
Cog6ToothIcon,
TicketIcon,
GlobeAltIcon,
ClockIcon,
EnvelopeIcon,
@@ -47,6 +49,8 @@ export default function AdminSettingsPage() {
maintenanceMode: false,
maintenanceMessage: null,
maintenanceMessageEs: null,
presaleClosureEnabled: true,
presaleCloseMinutesBefore: 120,
});
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
@@ -508,6 +512,69 @@ export default function AdminSettingsPage() {
</div>
</Card>
{/* Pre-sale Closure defaults */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<TicketIcon className="w-5 h-5 text-green-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Cierre de Preventa' : 'Pre-sale Closure'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Valor por defecto para los eventos. Cada evento puede cambiarlo en su ventana de edición.'
: 'Default for events. Each event can override this in its edit dialog.'}
</p>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg mb-4">
<div>
<p className="font-medium">
{locale === 'es' ? 'Cerrar la preventa antes del evento' : 'Close pre-sale before the event starts'}
</p>
<p className="text-sm text-gray-500">
{settings.presaleClosureEnabled
? (locale === 'es' ? 'Las inscripciones en línea se cierran antes del inicio' : 'Online registration stops before the start time')
: (locale === 'es' ? 'Las inscripciones siguen abiertas hasta el inicio' : 'Registration stays open until the event starts')}
</p>
</div>
<button
type="button"
onClick={() => updateSetting('presaleClosureEnabled', !settings.presaleClosureEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.presaleClosureEnabled ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.presaleClosureEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{settings.presaleClosureEnabled && (
<div className="max-w-md">
<DurationInput
label={locale === 'es' ? 'Cerrar la preventa este tiempo antes del inicio' : 'Close pre-sale this long before the start'}
valueMinutes={settings.presaleCloseMinutesBefore}
onChange={(minutes) => updateSetting('presaleCloseMinutesBefore', minutes)}
unitLabels={locale === 'es'
? { minutes: 'minutos', hours: 'horas', days: 'días' }
: { minutes: 'minutes', hours: 'hours', days: 'days' }}
helper={locale === 'es'
? 'Se aplica a los eventos que no tienen su propia configuración.'
: 'Applies to events that have not set their own value.'}
/>
</div>
)}
</div>
</Card>
{/* Maintenance Mode */}
<Card>
<div className="p-6">
@@ -0,0 +1,91 @@
'use client';
import { useEffect, useState } from 'react';
import Input from '@/components/ui/Input';
import { DurationUnit, durationToMinutes, minutesToDuration } from '@/lib/utils';
interface DurationInputProps {
label?: string;
/** Duration in minutes (the stored unit). */
valueMinutes: number;
onChange: (minutes: number) => void;
disabled?: boolean;
helper?: string;
/** Unit labels, overridable for Spanish admin pages. */
unitLabels?: Record<DurationUnit, string>;
}
const DEFAULT_UNIT_LABELS: Record<DurationUnit, string> = {
minutes: 'minutes',
hours: 'hours',
days: 'days',
};
/**
* Number + unit (minutes / hours / days) picker that always reports minutes.
* The unit is local UI state so switching hours -> minutes keeps the typed
* number rather than the stored value.
*/
export default function DurationInput({
label,
valueMinutes,
onChange,
disabled,
helper,
unitLabels = DEFAULT_UNIT_LABELS,
}: DurationInputProps) {
const initial = minutesToDuration(valueMinutes);
const [unit, setUnit] = useState<DurationUnit>(initial.unit);
const [value, setValue] = useState<string>(String(initial.value));
// Re-sync when the parent swaps in a new stored value (e.g. modal reopened
// for another event) that doesn't match what this input last reported.
useEffect(() => {
if (durationToMinutes(Number(value), unit) === valueMinutes) return;
const next = minutesToDuration(valueMinutes);
setUnit(next.unit);
setValue(String(next.value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueMinutes]);
const emit = (nextValue: string, nextUnit: DurationUnit) => {
const n = Number(nextValue);
onChange(durationToMinutes(Number.isFinite(n) ? n : 0, nextUnit));
};
return (
<div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label={label}
type="number"
min="0"
step="1"
value={value}
disabled={disabled}
onChange={(e) => {
setValue(e.target.value);
emit(e.target.value, unit);
}}
/>
</div>
<select
value={unit}
disabled={disabled}
onChange={(e) => {
const nextUnit = e.target.value as DurationUnit;
setUnit(nextUnit);
emit(value, nextUnit);
}}
className="px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:opacity-50"
>
{(Object.keys(unitLabels) as DurationUnit[]).map((u) => (
<option key={u} value={u}>{unitLabels[u]}</option>
))}
</select>
</div>
{helper && <p className="text-xs text-gray-500 mt-1">{helper}</p>}
</div>
);
}
+3 -1
View File
@@ -88,7 +88,9 @@
"spotsLeft": "spots left",
"soldOut": "Sold Out",
"cancelled": "Cancelled",
"eventEnded": "Event Ended"
"eventEnded": "Event Ended",
"registrationClosed": "Registration Closed",
"registrationClosesAt": "Registration closes {date}"
},
"booking": {
"join": "Join Event",
+3 -1
View File
@@ -88,7 +88,9 @@
"spotsLeft": "lugares disponibles",
"soldOut": "Agotado",
"cancelled": "Cancelado",
"eventEnded": "Evento Finalizado"
"eventEnded": "Evento Finalizado",
"registrationClosed": "Inscripciones Cerradas",
"registrationClosesAt": "Las inscripciones cierran el {date}"
},
"booking": {
"join": "Unirse al Evento",
+5
View File
@@ -18,6 +18,9 @@ export interface Event {
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
presaleClosureEnabled?: boolean | null; // null = inherit the site default
presaleCloseMinutesBefore?: number | null; // null = inherit the site default
presaleClosesAt?: string | null; // server-computed cutoff (ISO); null = never closes
bookedCount?: number; // paid seats (confirmed + checked_in)
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
@@ -484,6 +487,8 @@ export interface SiteSettings {
maintenanceMode: boolean;
maintenanceMessage?: string | null;
maintenanceMessageEs?: string | null;
presaleClosureEnabled: boolean;
presaleCloseMinutesBefore: number;
updatedAt?: string;
updatedBy?: string;
}
+34
View File
@@ -235,3 +235,37 @@ 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' };
}
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]);
}