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:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user