'use client'; import { useState, useEffect } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils'; import { isSafeExternalUrl } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; import type { AttendeeInfo, BookingFormData, BookingResult, BookingStep, PaymentMethod, } from './_types'; import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking'; import { useLightningWatcher } from '@/hooks/useLightningWatcher'; import { PayingStep } from './_steps/PayingStep'; import { ManualPaymentStep } from './_steps/ManualPaymentStep'; import { PendingApprovalStep } from './_steps/PendingApprovalStep'; import { SuccessStep } from './_steps/SuccessStep'; import { BookingFormStep } from './_steps/BookingFormStep'; export default function BookingPage() { const params = useParams(); const router = useRouter(); const searchParams = useSearchParams(); const { t, locale } = useLanguage(); const { user } = useAuth(); const [event, setEvent] = useState(null); const [paymentConfig, setPaymentConfig] = useState(null); const [loading, setLoading] = useState(true); const [step, setStep] = useState('form'); const [submitting, setSubmitting] = useState(false); const [bookingResult, setBookingResult] = useState(null); const [markingPaid, setMarkingPaid] = useState(false); // State for payer name (when paid under different name) const [paidUnderDifferentName, setPaidUnderDifferentName] = useState(false); const [payerName, setPayerName] = useState(''); // Quantity from URL param (default 1) const initialQuantity = Math.max(1, parseInt(searchParams.get('qty') || '1', 10)); const [ticketQuantity, setTicketQuantity] = useState(initialQuantity); // Attendees for multi-ticket bookings (ticket 1 uses main formData) const [attendees, setAttendees] = useState(() => Array(Math.max(0, initialQuantity - 1)).fill(null).map(() => ({ firstName: '', lastName: '' })) ); const [attendeeErrors, setAttendeeErrors] = useState<{ [key: number]: string }>({}); const [formData, setFormData] = useState({ firstName: '', lastName: '', email: '', phone: '', paymentMethod: '', ruc: '', }); const [errors, setErrors] = useState>>({}); // Terms & Privacy agreement (not persisted across page loads) const [agreedToTerms, setAgreedToTerms] = useState(false); const [termsError, setTermsError] = useState(null); // Handle RUC input change const handleRucChange = (e: React.ChangeEvent) => { const formatted = formatRuc(e.target.value); setFormData({ ...formData, ruc: formatted }); // Clear error on change if (errors.ruc) { setErrors({ ...errors, ruc: undefined }); } }; // Validate RUC on blur (optional field: base + "-" + check digit) const handleRucBlur = () => { if (!formData.ruc) return; if (!rucPattern.test(formData.ruc)) { setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') }); } }; useEffect(() => { if (params.eventId) { Promise.all([ eventsApi.getById(params.eventId as string), paymentOptionsApi.getForEvent(params.eventId as string), ]) .then(([eventRes, paymentRes]) => { if (!eventRes.event || !['published', 'unlisted'].includes(eventRes.event.status)) { toast.error('Event is not available for booking'); router.push('/events'); return; } // Redirect to external booking if enabled (only https:// targets are allowed) if ( eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl && isSafeExternalUrl(eventRes.event.externalBookingUrl) ) { window.location.href = eventRes.event.externalBookingUrl; return; } // Pre-sale closure: the booking API rejects after the cutoff, so // bounce back to the event page instead of showing a dead form. if (isPresaleClosed(eventRes.event)) { toast.error(t('events.details.registrationClosed')); router.push(`/events/${eventRes.event.slug}`); return; } // Server-authoritative availability — same formula the booking API // enforces, so a sold-out event is caught here, not at submit time. if (isEventSoldOut(eventRes.event)) { toast.error(t('events.details.soldOut')); router.push(`/events/${eventRes.event.slug}`); return; } const spotsLeft = eventSpotsLeft(eventRes.event); setEvent(eventRes.event); // Cap quantity by available spots (never allow requesting more than spotsLeft) setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft))); setAttendees((prev) => { const newQty = Math.min(initialQuantity, Math.max(1, spotsLeft)); const need = Math.max(0, newQty - 1); if (need === prev.length) return prev; return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' }); }); setPaymentConfig(paymentRes.paymentOptions); // No payment method is pre-selected; the user must choose one. }) .catch(() => router.push('/events')) .finally(() => setLoading(false)); } }, [params.eventId, router]); // Auto-fill form fields when user is logged in useEffect(() => { if (user) { setFormData(prev => { // Split user's full name into first and last name const nameParts = (user.name || '').trim().split(' '); const firstName = nameParts[0] || ''; const lastName = nameParts.slice(1).join(' ') || ''; return { ...prev, firstName: prev.firstName || firstName, lastName: prev.lastName || lastName, email: prev.email || user.email || '', phone: prev.phone || user.phone || '', ruc: prev.ruc || formatRucDisplay(user.rucNumber), }; }); } }, [user]); // Clear the terms error as soon as the user agrees useEffect(() => { if (agreedToTerms && termsError) { setTermsError(null); } }, [agreedToTerms, termsError]); // Scroll to top when moving between booking steps (esp. mobile, where // the submit button sits at the bottom of a long form) useEffect(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, [step]); const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); const validateForm = (): boolean => { const newErrors: Partial> = {}; const newAttendeeErrors: { [key: number]: string } = {}; if (!formData.firstName.trim() || formData.firstName.length < 2) { newErrors.firstName = t('booking.form.errors.firstNameRequired'); } // lastName is optional - only validate if provided if (formData.lastName.trim() && formData.lastName.length < 2) { newErrors.lastName = t('booking.form.errors.lastNameTooShort'); } if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { newErrors.email = t('booking.form.errors.emailInvalid'); } // phone is optional - only validate if provided if (formData.phone.trim() && formData.phone.length < 6) { newErrors.phone = t('booking.form.errors.phoneTooShort'); } // RUC validation (optional field - base + "-" + check digit if filled) if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) { newErrors.ruc = t('booking.form.errors.rucInvalidFormat'); } // Payment method must be explicitly chosen and currently enabled const availableMethods = buildPaymentMethods(paymentConfig, locale); if ( !formData.paymentMethod || !availableMethods.some((m) => m.id === formData.paymentMethod) ) { newErrors.paymentMethod = t('booking.form.errors.paymentMethodRequired'); } // Validate additional attendees (if multi-ticket) attendees.forEach((attendee, index) => { if (!attendee.firstName.trim() || attendee.firstName.length < 2) { newAttendeeErrors[index] = locale === 'es' ? 'Ingresa el nombre del asistente' : 'Enter attendee name'; } }); setErrors(newErrors); setAttendeeErrors(newAttendeeErrors); 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 ); }; // Watch for Lightning payment confirmation while on the paying step. useLightningWatcher( step === 'paying', bookingResult?.ticketId, locale, () => setStep('success'), () => {} ); // Handle "I Have Paid" button click const handleMarkPaymentSent = async () => { if (!bookingResult) return; // Validate payer name if paid under different name if (paidUnderDifferentName && !payerName.trim()) { toast.error(locale === 'es' ? 'Por favor ingresa el nombre del pagador' : 'Please enter the payer name'); return; } setMarkingPaid(true); try { await ticketsApi.markPaymentSent( bookingResult.ticketId, paidUnderDifferentName ? payerName.trim() : undefined ); setStep('pending_approval'); toast.success(locale === 'es' ? 'Pago marcado como enviado. Esperando aprobación.' : 'Payment marked as sent. Waiting for approval.'); } catch (error: any) { toast.error(error.message || 'Failed to mark payment as sent'); } finally { setMarkingPaid(false); } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!agreedToTerms) { setTermsError(t('booking.form.errors.termsRequired')); return; } if (!event || !validateForm()) return; setSubmitting(true); try { // Build attendees array: first attendee from main form, rest from attendees state const allAttendees = [ { firstName: formData.firstName, lastName: formData.lastName }, ...attendees ]; const response = await ticketsApi.book({ eventId: event.id, firstName: formData.firstName, lastName: formData.lastName, email: formData.email, phone: formData.phone, preferredLanguage: locale as 'en' | 'es', paymentMethod: formData.paymentMethod as PaymentMethod, ...(formData.ruc.trim() && { ruc: formData.ruc.trim() }), // Include attendees array for multi-ticket bookings ...(allAttendees.length > 1 && { attendees: allAttendees }), }); const { ticket, tickets: ticketsList, bookingId, lightningInvoice } = response as any; const ticketCount = ticketsList?.length || 1; const primaryTicket = ticket || ticketsList?.[0]; // If Lightning payment with invoice, go to paying step if (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) { const result: BookingResult = { ticketId: primaryTicket.id, ticketIds: ticketsList?.map((t: any) => t.id), bookingId, qrCode: primaryTicket.qrCode, qrCodes: ticketsList?.map((t: any) => t.qrCode), paymentMethod: formData.paymentMethod as PaymentMethod, ticketCount, lightningInvoice: { paymentHash: lightningInvoice.paymentHash, paymentRequest: lightningInvoice.paymentRequest, amount: lightningInvoice.amount, fiatAmount: lightningInvoice.fiatAmount, fiatCurrency: lightningInvoice.fiatCurrency, expiry: lightningInvoice.expiry, }, }; setBookingResult(result); setStep('paying'); // Payment confirmation is handled by the paying-step watcher effect. } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { // Manual payment methods - show payment details setBookingResult({ ticketId: primaryTicket.id, ticketIds: ticketsList?.map((t: any) => t.id), bookingId, qrCode: primaryTicket.qrCode, qrCodes: ticketsList?.map((t: any) => t.qrCode), paymentMethod: formData.paymentMethod, ticketCount, }); // Fetch full payment credentials now that we hold a ticket capability token try { const { paymentOptions } = await paymentOptionsApi.getForEvent( params.eventId as string, primaryTicket.id ); setPaymentConfig(paymentOptions); } catch { // Keep the flags-only config from initial load if the gated fetch fails } setStep('manual_payment'); } else { // Cash payment - go straight to success setBookingResult({ ticketId: primaryTicket.id, ticketIds: ticketsList?.map((t: any) => t.id), bookingId, qrCode: primaryTicket.qrCode, qrCodes: ticketsList?.map((t: any) => t.qrCode), paymentMethod: formData.paymentMethod as PaymentMethod, ticketCount, }); setStep('success'); toast.success(t('booking.success.message')); } } catch (error: any) { const message = String(error?.message || ''); // Pre-sale closed while the form was open: send the user back to the // event page, which now shows the "Registration Closed" state. if (/registration .*closed/i.test(message)) { toast.error(t('events.details.registrationClosed')); if (event?.slug) router.push(`/events/${event.slug}`); return; } toast.error(error.message || t('booking.form.errors.bookingFailed')); // Capacity race on the last seats: refresh availability so the page // reflects reality (sold-out block / lower quantity cap) instead of the // stale counts loaded when the form was opened. if (/sold out|seats available/i.test(message)) { try { const { event: freshEvent } = await eventsApi.getById(params.eventId as string); setEvent(freshEvent); const freshSpots = eventSpotsLeft(freshEvent); if (freshSpots > 0) { setTicketQuantity((q) => Math.min(q, freshSpots)); setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1))); } } catch { // Keep the stale event state if the refresh fails } } } finally { setSubmitting(false); } }; // Build payment methods list based on configuration const paymentMethods = buildPaymentMethods(paymentConfig, locale); if (loading) { return (
); } if (!event) { return null; } const spotsLeft = eventSpotsLeft(event); const isSoldOut = isEventSoldOut(event); // Paying step - waiting for Lightning payment (compact design) if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) { return ( ); } // Manual payment step - showing bank transfer details or TPago link if (step === 'manual_payment' && bookingResult && paymentConfig) { return ( ); } // Pending approval step - user has marked payment as sent if (step === 'pending_approval' && bookingResult) { return ( ); } // Success step if (step === 'success' && bookingResult) { return ( ); } return ( ); }