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
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user