Show pre-sale close time as a duration before the event starts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-09-17 15:40:30 +00:00
co-authored by Cursor
parent a70333f5e4
commit 0b7f0a474e
5 changed files with 30 additions and 16 deletions
@@ -5,7 +5,7 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils'; import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons'; import ShareButtons from '@/components/ShareButtons';
@@ -72,6 +72,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
// Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent // Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent
const presaleClosed = mounted ? isPresaleClosed(event) : false; const presaleClosed = mounted ? isPresaleClosed(event) : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted'); const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted');
// Effective lead time (event override or site default), derived from the server cutoff
const presaleLeadMinutes = event.presaleClosesAt
? Math.max(0, Math.round((parseDate(event.startDatetime).getTime() - parseDate(event.presaleClosesAt).getTime()) / 60_000))
: null;
// Booking card content - reused for mobile and desktop positions // Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => ( const BookingCardContent = () => (
@@ -153,11 +157,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</Button> </Button>
)} )}
{canBook && !event.externalBookingEnabled && event.presaleClosesAt && ( {canBook && !event.externalBookingEnabled && presaleLeadMinutes !== null && (
<p className="mt-3 text-center text-xs text-gray-500"> <p className="mt-3 text-center text-xs text-gray-400">
{t('events.details.registrationClosesAt', { {presaleLeadMinutes > 0
date: `${formatDate(event.presaleClosesAt)} ${fmtTime(event.presaleClosesAt)}`, ? t('events.details.presaleClosesBefore', {
})} duration: formatDurationWords(presaleLeadMinutes, locale as 'en' | 'es'),
})
: t('events.details.presaleClosesAtStart')}
</p> </p>
)} )}
@@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { parseDate, EVENT_TIMEZONE, minutesToDuration } from '@/lib/utils'; import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
interface EventFormData { interface EventFormData {
title: string; title: string;
@@ -43,12 +43,6 @@ interface PresaleDefaults {
const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefore: 120 }; 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 = { const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '', title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '', shortDescription: '', shortDescriptionEs: '',
@@ -425,7 +419,7 @@ export default function EventFormModal({
)} )}
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Site default: {presaleDefaults.enabled Site default: {presaleDefaults.enabled
? `closes ${describeDuration(presaleDefaults.minutesBefore)} before the start` ? `closes ${formatDurationWords(presaleDefaults.minutesBefore)} before the start`
: 'off'} : 'off'}
{' '}(Admin Settings General). {' '}(Admin Settings General).
</p> </p>
+2 -1
View File
@@ -90,7 +90,8 @@
"cancelled": "Cancelled", "cancelled": "Cancelled",
"eventEnded": "Event Ended", "eventEnded": "Event Ended",
"registrationClosed": "Registration Closed", "registrationClosed": "Registration Closed",
"registrationClosesAt": "Registration closes {date}" "presaleClosesBefore": "Pre-sale registration closes {duration} before the event begins.",
"presaleClosesAtStart": "Pre-sale registration closes when the event begins."
}, },
"booking": { "booking": {
"join": "Join Event", "join": "Join Event",
+2 -1
View File
@@ -90,7 +90,8 @@
"cancelled": "Cancelado", "cancelled": "Cancelado",
"eventEnded": "Evento Finalizado", "eventEnded": "Evento Finalizado",
"registrationClosed": "Inscripciones Cerradas", "registrationClosed": "Inscripciones Cerradas",
"registrationClosesAt": "Las inscripciones cierran el {date}" "presaleClosesBefore": "La preventa cierra {duration} antes de que comience el evento.",
"presaleClosesAtStart": "La preventa cierra cuando comienza el evento."
}, },
"booking": { "booking": {
"join": "Unirse al Evento", "join": "Unirse al Evento",
+12
View File
@@ -265,6 +265,18 @@ export function minutesToDuration(minutes: number): { value: number; unit: Durat
return { value: safe, unit: 'minutes' }; return { value: safe, unit: 'minutes' };
} }
const DURATION_WORDS: Record<'en' | 'es', Record<DurationUnit, [string, string]>> = {
en: { minutes: ['minute', 'minutes'], hours: ['hour', 'hours'], days: ['day', 'days'] },
es: { minutes: ['minuto', 'minutos'], hours: ['hora', 'horas'], days: ['día', 'días'] },
};
/** "30 minutes", "1 hour", "2 días" — largest unit that divides the minutes evenly. */
export function formatDurationWords(minutes: number, locale: 'en' | 'es' = 'en'): string {
const { value, unit } = minutesToDuration(minutes);
const [one, many] = DURATION_WORDS[locale][unit];
return `${value} ${value === 1 ? one : many}`;
}
export function durationToMinutes(value: number, unit: DurationUnit): number { export function durationToMinutes(value: number, unit: DurationUnit): number {
const safe = Number.isFinite(value) && value >= 0 ? value : 0; const safe = Number.isFinite(value) && value >= 0 ? value : 0;
return Math.round(safe * MINUTES_PER_UNIT[unit]); return Math.round(safe * MINUTES_PER_UNIT[unit]);