From 0b7f0a474e224e57e96e30b056bf68a8fc1cec35 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 17 Sep 2026 15:40:30 +0000 Subject: [PATCH] Show pre-sale close time as a duration before the event starts. Co-authored-by: Cursor --- .../(public)/events/[id]/EventDetailClient.tsx | 18 ++++++++++++------ .../events/_components/EventFormModal.tsx | 10 ++-------- frontend/src/i18n/locales/en.json | 3 ++- frontend/src/i18n/locales/es.json | 3 ++- frontend/src/lib/utils.ts | 12 ++++++++++++ 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index 0b1fb2d..050aeae 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -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, isPresaleClosed } from '@/lib/utils'; +import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; 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 const presaleClosed = mounted ? isPresaleClosed(event) : false; 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 const BookingCardContent = () => ( @@ -153,11 +157,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail )} - {canBook && !event.externalBookingEnabled && event.presaleClosesAt && ( -

- {t('events.details.registrationClosesAt', { - date: `${formatDate(event.presaleClosesAt)} ${fmtTime(event.presaleClosesAt)}`, - })} + {canBook && !event.externalBookingEnabled && presaleLeadMinutes !== null && ( +

+ {presaleLeadMinutes > 0 + ? t('events.details.presaleClosesBefore', { + duration: formatDurationWords(presaleLeadMinutes, locale as 'en' | 'es'), + }) + : t('events.details.presaleClosesAtStart')}

)} diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx index 514815e..c8c49c5 100644 --- a/frontend/src/app/admin/events/_components/EventFormModal.tsx +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -10,7 +10,7 @@ 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, minutesToDuration } from '@/lib/utils'; +import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils'; interface EventFormData { title: string; @@ -43,12 +43,6 @@ interface PresaleDefaults { 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 = { title: '', titleEs: '', slug: '', description: '', descriptionEs: '', shortDescription: '', shortDescriptionEs: '', @@ -425,7 +419,7 @@ export default function EventFormModal({ )}

Site default: {presaleDefaults.enabled - ? `closes ${describeDuration(presaleDefaults.minutesBefore)} before the start` + ? `closes ${formatDurationWords(presaleDefaults.minutesBefore)} before the start` : 'off'} {' '}(Admin › Settings › General).

diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d32de1c..b982f97 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -90,7 +90,8 @@ "cancelled": "Cancelled", "eventEnded": "Event Ended", "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": { "join": "Join Event", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 1061ef3..5771049 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -90,7 +90,8 @@ "cancelled": "Cancelado", "eventEnded": "Evento Finalizado", "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": { "join": "Unirse al Evento", diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 10f98ee..fe6147a 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -265,6 +265,18 @@ export function minutesToDuration(minutes: number): { value: number; unit: Durat return { value: safe, unit: 'minutes' }; } +const DURATION_WORDS: Record<'en' | 'es', Record> = { + 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 { const safe = Number.isFinite(value) && value >= 0 ? value : 0; return Math.round(safe * MINUTES_PER_UNIT[unit]);