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:
co-authored by
Claude Fable 5.1
parent
745af4184f
commit
59acc32a46
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user