Booking flow: required terms and privacy checkbox with i18n

Also includes admin, dashboard, and API updates; PWA icon assets; and
assorted layout and utility changes on dev.
This commit is contained in:
Michilis
2026-04-27 03:21:15 +00:00
parent f8ebc3760d
commit 3dfb1689ad
35 changed files with 575 additions and 106 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -110,6 +110,10 @@ export default function BookingPage() {
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
// Terms & Privacy agreement (not persisted across page loads)
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [termsError, setTermsError] = useState<string | null>(null);
const rucPattern = /^\d{6,10}$/;
// Format RUC input: digits only, max 10
@@ -217,6 +221,13 @@ export default function BookingPage() {
}
}, [user]);
// Clear the terms error as soon as the user agrees
useEffect(() => {
if (agreedToTerms && termsError) {
setTermsError(null);
}
}, [agreedToTerms, termsError]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
@@ -261,7 +272,20 @@ export default function BookingPage() {
setErrors(newErrors);
setAttendeeErrors(newAttendeeErrors);
return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0;
let termsOk = true;
if (!agreedToTerms) {
setTermsError(t('booking.form.errors.termsRequired'));
termsOk = false;
} else {
setTermsError(null);
}
return (
Object.keys(newErrors).length === 0 &&
Object.keys(newAttendeeErrors).length === 0 &&
termsOk
);
};
// Connect to SSE for real-time payment updates
@@ -376,6 +400,10 @@ export default function BookingPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!agreedToTerms) {
setTermsError(t('booking.form.errors.termsRequired'));
return;
}
if (!event || !validateForm()) return;
setSubmitting(true);
@@ -1323,13 +1351,58 @@ export default function BookingPage() {
</div>
</Card>
{/* Terms & Privacy agreement */}
<Card className="mb-6 p-6">
<div className="flex items-start gap-3">
<input
id="booking-terms-agree"
type="checkbox"
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
aria-required="true"
aria-invalid={termsError ? true : undefined}
aria-describedby={termsError ? 'booking-terms-error' : undefined}
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
/>
<label
htmlFor="booking-terms-agree"
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
>
{t('booking.form.termsAgreePart1')}
<Link
href="/legal/terms-policy"
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.termsOfService')}
</Link>
{t('booking.form.termsAgreePart2')}
<Link
href="/legal/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.privacyPolicy')}
</Link>
{t('booking.form.termsAgreePart3')}
</label>
</div>
{termsError && (
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
{termsError}
</p>
)}
</Card>
{/* Submit Button */}
<Button
type="submit"
size="lg"
className="w-full"
isLoading={submitting}
disabled={paymentMethods.length === 0}
disabled={paymentMethods.length === 0 || !agreedToTerms}
>
{formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot')
@@ -1338,10 +1411,6 @@ export default function BookingPage() {
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
}
</Button>
<p className="text-center text-sm text-gray-500 mt-4">
{t('booking.form.termsNote')}
</p>
</form>
)}
</div>

View File

@@ -102,11 +102,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
<div className="mt-4 md:mt-5 space-y-2">
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{formatDate(nextEvent.startDatetime)}</span>
<span suppressHydrationWarning>{formatDate(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{fmtTime(nextEvent.startDatetime)}</span>
<span suppressHydrationWarning>{fmtTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserPayment } from '@/lib/api';
import { parseDate } from '@/lib/utils';
interface PaymentsTabProps {
payments: UserPayment[];
@@ -21,7 +22,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { dashboardApi, UserProfile } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
interface ProfileTabProps {
@@ -116,7 +117,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
</span>
<span className="font-medium">
{profile?.memberSince
? new Date(profile.memberSince).toLocaleDateString(
? parseDate(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
)

View File

@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export default function SecurityTab() {
@@ -147,7 +148,7 @@ export default function SecurityTab() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserTicket } from '@/lib/api';
import { parseDate } from '@/lib/utils';
interface TicketsTabProps {
tickets: UserTicket[];
@@ -26,7 +27,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -3,6 +3,7 @@ import HeroSection from './components/HeroSection';
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
import AboutSection from './components/AboutSection';
import MediaCarouselSection from './components/MediaCarouselSection';
import { parseDate } from '@/lib/utils';
import NewsletterSection from './components/NewsletterSection';
import HomepageFaqSection from './components/HomepageFaqSection';
import { getCarouselImages } from '@/lib/carouselImages';
@@ -63,7 +64,7 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
const eventDate = parseDate(event.startDatetime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
@@ -116,7 +117,7 @@ export default function AdminBookingsPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',

View File

@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { parseDate } from '@/lib/utils';
export default function AdminContactsPage() {
const { t, locale } = useLanguage();
@@ -44,7 +45,7 @@ export default function AdminContactsPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -391,7 +392,7 @@ export default function AdminEmailsPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
@@ -572,7 +573,7 @@ export default function AdminEmailsPage() {
<div className="flex items-center gap-2">
{hasDraft && (
<span className="text-xs text-gray-500">
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
Draft saved {composeForm.savedAt ? parseDate(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
</span>
)}
<Button variant="outline" size="sm" onClick={saveDraft}>
@@ -598,7 +599,7 @@ export default function AdminEmailsPage() {
<option value="">Choose an event</option>
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
<option key={event.id} value={event.id}>
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
{event.title} - {parseDate(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</option>
))}
</select>

View File

@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
import { formatDateLong, formatDateCompact, formatTime, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
@@ -38,6 +39,7 @@ import {
ArrowDownTrayIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
StarIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
@@ -88,6 +90,14 @@ export default function AdminEventDetailPage() {
phone: '',
adminNote: '',
});
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
const [inviteGuestForm, setInviteGuestForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
const [submitting, setSubmitting] = useState(false);
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
@@ -212,32 +222,9 @@ export default function AdminEventDetailPage() {
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};
const formatDateShort = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'America/Asuncion',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatCurrency = (amount: number, currency: string) => {
if (currency === 'PYG') {
@@ -376,6 +363,30 @@ export default function AdminEventDetailPage() {
}
};
const handleInviteGuest = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.guestCreate({
eventId: event.id,
firstName: inviteGuestForm.firstName,
lastName: inviteGuestForm.lastName || undefined,
email: inviteGuestForm.email || undefined,
phone: inviteGuestForm.phone || undefined,
adminNote: inviteGuestForm.adminNote || undefined,
});
toast.success('Guest invited successfully');
setShowInviteGuestModal(false);
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to invite guest');
} finally {
setSubmitting(false);
}
};
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
if (!event) return;
setExporting(true);
@@ -466,7 +477,7 @@ export default function AdminEventDetailPage() {
ticketId: 'TKT-PREVIEW',
eventTitle: event?.title || '',
eventDate: event ? formatDate(event.startDatetime) : '',
eventTime: event ? formatTime(event.startDatetime) : '',
eventTime: event ? fmtTime(event.startDatetime) : '',
eventLocation: event?.location || '',
eventLocationUrl: event?.locationUrl || '',
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
@@ -538,7 +549,9 @@ export default function AdminEventDetailPage() {
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const revenue = (confirmedCount + checkedInCount) * event.price;
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
@@ -573,7 +586,7 @@ export default function AdminEventDetailPage() {
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {formatTime(event.startDatetime)}</p>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {fmtTime(event.startDatetime)}</p>
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
@@ -623,7 +636,7 @@ export default function AdminEventDetailPage() {
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CalendarIcon className="w-3.5 h-3.5" />
{formatDateShort(event.startDatetime)} {formatTime(event.startDatetime)}{event.endDatetime && ` ${formatTime(event.endDatetime)}`}
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` ${fmtTime(event.endDatetime)}`}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<MapPinIcon className="w-3.5 h-3.5" />
@@ -778,7 +791,7 @@ export default function AdminEventDetailPage() {
<div>
<p className="font-medium text-sm">Date & Time</p>
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
<p className="text-sm text-gray-600">{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}</p>
<p className="text-sm text-gray-600">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}</p>
</div>
</div>
<div className="flex items-start gap-3">
@@ -909,6 +922,9 @@ export default function AdminEventDetailPage() {
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
</DropdownItem>
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
</DropdownItem>
</Dropdown>
</div>
{(searchQuery || statusFilter !== 'all') && (
@@ -1006,15 +1022,20 @@ export default function AdminEventDetailPage() {
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td>
<td className="px-4 py-2.5">
{getStatusBadge(ticket.status, true)}
<div className="flex items-center gap-1 flex-wrap">
{getStatusBadge(ticket.status, true)}
{ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
</div>
{ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5">
{new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
</p>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
@@ -1066,14 +1087,17 @@ export default function AdminEventDetailPage() {
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
{getStatusBadge(ticket.status, true)}
{ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
{ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`}
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
</p>
<div className="flex items-center gap-1">
{primary && (
@@ -1230,8 +1254,8 @@ export default function AdminEventDetailPage() {
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{ticket.checkinAt ? (
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
})
) : (
<span className="text-gray-300"></span>
@@ -1293,7 +1317,7 @@ export default function AdminEventDetailPage() {
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">
{ticket.checkinAt
? `Checked in ${new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
: 'Not checked in'}
</p>
<div className="flex items-center gap-1">
@@ -1837,6 +1861,16 @@ export default function AdminEventDetailPage() {
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
</div>
</button>
<button
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<StarIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Invite Guest</p>
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</p>
</div>
</button>
</div>
</BottomSheet>
@@ -2046,6 +2080,86 @@ export default function AdminEventDetailPage() {
</div>
)}
{/* Invite Guest Modal */}
{showInviteGuestModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowInviteGuestModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Invite Guest</h2>
<p className="text-xs text-gray-500">Free ticket not counted in revenue</p>
</div>
<button onClick={() => setShowInviteGuestModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={inviteGuestForm.firstName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={inviteGuestForm.lastName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={inviteGuestForm.email}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com (optional)" />
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={inviteGuestForm.phone}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={inviteGuestForm.adminNote}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-800">
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<StarIcon className="w-4 h-4 mr-1.5" />
Invite Guest
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Note Modal */}
{showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">

View File

@@ -14,6 +14,7 @@ import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateI
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
export default function AdminEventsPage() {
const router = useRouter();
@@ -123,13 +124,19 @@ export default function AdminEventsPage() {
};
const isoToLocalDatetime = (isoString: string): string => {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
const date = parseDate(isoString);
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: EVENT_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: string) => parts.find(p => p.type === type)!.value;
const h = get('hour') === '24' ? '00' : get('hour');
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
};
const handleEdit = (event: Event) => {
@@ -167,8 +174,8 @@ export default function AdminEventsPage() {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
startDatetime: new Date(formData.startDatetime).toISOString(),
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
startDatetime: formData.startDatetime,
endDatetime: formData.endDatetime || undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
@@ -214,7 +221,7 @@ export default function AdminEventsPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
});
};

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { mediaApi, Media } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
@@ -108,7 +109,7 @@ export default function AdminGalleryPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi, LegalPage } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -158,7 +159,7 @@ export default function AdminLegalPagesPage() {
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -15,6 +15,7 @@ import {
UserGroupIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import { parseDate } from '@/lib/utils';
export default function AdminDashboardPage() {
const { t, locale } = useLanguage();
@@ -30,7 +31,7 @@ export default function AdminDashboardPage() {
}, []);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -203,7 +204,7 @@ export default function AdminPaymentsPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',

View File

@@ -18,6 +18,7 @@ import {
VideoCameraIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import clsx from 'clsx';
// ─── Types ───────────────────────────────────────────────────
@@ -324,7 +325,7 @@ function InvalidTicketScreen({
const reasonDetail: Record<InvalidReason, string> = {
already_checked_in: validation?.ticket?.checkinAt
? `Checked in at ${new Date(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
? `Checked in at ${parseDate(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
: 'This ticket was already used',
cancelled: 'This ticket has been cancelled and is no longer valid.',
not_found: error || 'No ticket matching this code was found.',
@@ -765,7 +766,7 @@ export default function AdminScannerPage() {
setRecentCheckins((prev) => [
{
name: result.ticket.attendeeName || 'Guest',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
ticketId: scanResult.validation!.ticket!.id,
},
...prev.slice(0, 19),
@@ -796,7 +797,7 @@ export default function AdminScannerPage() {
setRecentCheckins((prev) => [
{
name: result.ticket.attendeeName || 'Guest',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
ticketId: searchDetailValidation!.ticket!.id,
},
...prev.slice(0, 19),

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -319,7 +320,7 @@ export default function AdminSettingsPage() {
<div>
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
<p className="text-sm text-amber-700">
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
{parseDate(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -107,7 +108,7 @@ export default function AdminTicketsPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
});
};

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -104,7 +105,7 @@ export default function AdminUsersPage() {
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
});
};

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { parseDate } from '@/lib/utils';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -56,7 +57,7 @@ async function getUpcomingEvents(): Promise<LlmsEvent[]> {
const EVENT_TIMEZONE = 'America/Asuncion';
function formatEventDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
return parseDate(dateStr).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -66,7 +67,7 @@ function formatEventDate(dateStr: string): string {
}
function formatEventTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-US', {
return parseDate(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
@@ -80,7 +81,7 @@ function formatPrice(price: number, currency: string): string {
}
function formatISODate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-CA', {
return parseDate(dateStr).toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -89,7 +90,7 @@ function formatISODate(dateStr: string): string {
}
function formatISOTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-GB', {
return parseDate(dateStr).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,

View File

@@ -29,6 +29,7 @@ export default function Footer() {
width={140}
height={40}
className="h-10 w-auto"
style={{ width: 'auto' }}
/>
</Link>
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}>

View File

@@ -124,6 +124,7 @@ export default function Header() {
width={140}
height={40}
className="h-10 w-auto"
style={{ width: 'auto' }}
priority
/>
</Link>
@@ -219,6 +220,7 @@ export default function Header() {
width={100}
height={28}
className="h-7 w-auto"
style={{ width: 'auto' }}
/>
<button
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"

View File

@@ -117,7 +117,11 @@
"rucOptional": "Optional - for invoice",
"reserveSpot": "Reserve My Spot",
"proceedPayment": "Proceed to Payment",
"termsNote": "By booking, you agree to our terms and conditions.",
"termsAgreePart1": "I agree to the ",
"termsOfService": "Terms of Service",
"termsAgreePart2": " and ",
"privacyPolicy": "Privacy Policy",
"termsAgreePart3": ".",
"soldOutMessage": "This event is fully booked. Check back later or browse other events.",
"errors": {
"nameRequired": "Please enter your full name",
@@ -129,7 +133,8 @@
"phoneRequired": "Phone number is required",
"bookingFailed": "Booking failed. Please try again.",
"rucInvalidFormat": "Invalid format. Example: 12345678-9",
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number."
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number.",
"termsRequired": "You must agree to the Terms of Service and Privacy Policy to continue."
}
},
"summary": {

View File

@@ -117,7 +117,11 @@
"rucOptional": "Opcional - para facturación",
"reserveSpot": "Reservar Mi Lugar",
"proceedPayment": "Proceder al Pago",
"termsNote": "Al reservar, aceptas nuestros términos y condiciones.",
"termsAgreePart1": "Acepto los ",
"termsOfService": "Términos de Servicio",
"termsAgreePart2": " y la ",
"privacyPolicy": "Política de Privacidad",
"termsAgreePart3": ".",
"soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.",
"errors": {
"nameRequired": "Por favor ingresa tu nombre completo",
@@ -129,7 +133,8 @@
"phoneRequired": "El número de teléfono es requerido",
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
"rucInvalidCheckDigit": "RUC inválido. Verifique el número."
"rucInvalidCheckDigit": "RUC inválido. Verifique el número.",
"termsRequired": "Debes aceptar los Términos de Servicio y la Política de Privacidad para continuar."
}
},
"summary": {

View File

@@ -179,6 +179,20 @@ export const ticketsApi = {
method: 'POST',
body: JSON.stringify(data),
}),
guestCreate: (data: {
eventId: string;
firstName: string;
lastName?: string;
email?: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
method: 'POST',
body: JSON.stringify(data),
}),
checkPaymentStatus: (ticketId: string) =>
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
@@ -550,6 +564,7 @@ export interface Ticket {
checkedInByAdminId?: string;
qrCode: string;
adminNote?: string;
isGuest?: boolean;
createdAt: string;
event?: Event;
payment?: Payment;

View File

@@ -4,9 +4,14 @@
// All helpers pin the timezone to America/Asuncion so the output is identical
// on the server (often UTC) and the client (user's local TZ). This prevents
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
//
// IMPORTANT — parseDate() must be used instead of raw `new Date(str)` so that
// ISO-like strings without a timezone suffix (e.g. "2026-04-02T14:00:00") are
// always treated as UTC. Without this, the same string produces a different
// instant on server (Node TZ) vs client (browser / DevTools TZ).
// ---------------------------------------------------------------------------
const EVENT_TIMEZONE = 'America/Asuncion';
export const EVENT_TIMEZONE = 'America/Asuncion';
type Locale = 'en' | 'es';
@@ -14,11 +19,29 @@ function pickLocale(locale: Locale): string {
return locale === 'es' ? 'es-ES' : 'en-US';
}
// Matches ISO-like strings that have NO timezone indicator (Z, +HH:MM, etc.)
const NAIVE_ISO_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
/**
* Parse a date string into a deterministic Date object.
*
* If the string looks like an ISO datetime but lacks a timezone suffix it is
* ambiguous — `new Date()` would interpret it in the environment's local
* timezone which differs between Node (SSR) and the browser (hydration).
* We normalise by appending "Z" so parsing always targets UTC.
*/
export function parseDate(dateStr: string): Date {
if (NAIVE_ISO_RE.test(dateStr)) {
return new Date(dateStr + 'Z');
}
return new Date(dateStr);
}
/**
* "Sat, Feb 14" / "sáb, 14 feb"
*/
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'short',
month: 'short',
day: 'numeric',
@@ -30,7 +53,7 @@ export function formatDateShort(dateStr: string, locale: Locale = 'en'): string
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
*/
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -43,7 +66,7 @@ export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
* "February 14, 2026" / "14 de febrero de 2026" (no weekday)
*/
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -55,7 +78,7 @@ export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string
* "Feb 14, 2026" / "14 feb 2026"
*/
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
month: 'short',
day: 'numeric',
year: 'numeric',
@@ -67,7 +90,7 @@ export function formatDateCompact(dateStr: string, locale: Locale = 'en'): strin
* "04:30 PM" / "16:30"
*/
export function formatTime(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleTimeString(pickLocale(locale), {
return parseDate(dateStr).toLocaleTimeString(pickLocale(locale), {
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
@@ -78,7 +101,7 @@ export function formatTime(dateStr: string, locale: Locale = 'en'): string {
* "Feb 14, 2026, 04:30 PM" — compact date + time combined
*/
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleString(pickLocale(locale), {
return parseDate(dateStr).toLocaleString(pickLocale(locale), {
month: 'short',
day: 'numeric',
year: 'numeric',
@@ -92,7 +115,7 @@ export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
* "Sat, Feb 14, 04:30 PM" — short date + time combined
*/
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleString(pickLocale(locale), {
return parseDate(dateStr).toLocaleString(pickLocale(locale), {
weekday: 'short',
month: 'short',
day: 'numeric',