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}`)