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 { 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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user