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

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',