Files
Spanglish/frontend/src/app/(public)/book/[eventId]/page.tsx
T
MichilisandClaude Fable 5.1 59acc32a46 Close pre-sale registration a configurable time before events start.
Events gain nullable presale_closure_enabled / presale_close_minutes_before
overrides; null inherits the new site_settings defaults (enabled, 120 min).
A shared resolver computes the effective cutoff, which the public event API
exposes as presaleClosesAt and the public booking endpoint enforces. Door and
admin ticket creation are not gated.

Admin: toggle + duration picker in the event modal (pre-filled from the site
default) and a matching default card on Settings › General. Public: the event
page shows "Registration Closed" after the cutoff and the checkout page
redirects back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 20:52:09 +00:00

510 lines
18 KiB
TypeScript

'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<Event | null>(null);
const [paymentConfig, setPaymentConfig] = useState<PaymentOptionsConfig | null>(null);
const [loading, setLoading] = useState(true);
const [step, setStep] = useState<BookingStep>('form');
const [submitting, setSubmitting] = useState(false);
const [bookingResult, setBookingResult] = useState<BookingResult | null>(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<AttendeeInfo[]>(() =>
Array(Math.max(0, initialQuantity - 1)).fill(null).map(() => ({ firstName: '', lastName: '' }))
);
const [attendeeErrors, setAttendeeErrors] = useState<{ [key: number]: string }>({});
const [formData, setFormData] = useState<BookingFormData>({
firstName: '',
lastName: '',
email: '',
phone: '',
paymentMethod: '',
ruc: '',
});
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);
// Handle RUC input change
const handleRucChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<Record<keyof BookingFormData, string>> = {};
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 (
<div className="section-padding">
<div className="container-page max-w-2xl text-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
</div>
);
}
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 (
<PayingStep
invoice={bookingResult.lightningInvoice}
qrCode={bookingResult.qrCode}
locale={locale}
/>
);
}
// Manual payment step - showing bank transfer details or TPago link
if (step === 'manual_payment' && bookingResult && paymentConfig) {
return (
<ManualPaymentStep
bookingResult={bookingResult}
event={event}
paymentConfig={paymentConfig}
locale={locale}
paidUnderDifferentName={paidUnderDifferentName}
setPaidUnderDifferentName={setPaidUnderDifferentName}
payerName={payerName}
setPayerName={setPayerName}
markingPaid={markingPaid}
onMarkPaymentSent={handleMarkPaymentSent}
/>
);
}
// Pending approval step - user has marked payment as sent
if (step === 'pending_approval' && bookingResult) {
return (
<PendingApprovalStep
bookingResult={bookingResult}
event={event}
locale={locale}
t={t}
formatDate={formatDate}
fmtTime={fmtTime}
/>
);
}
// Success step
if (step === 'success' && bookingResult) {
return (
<SuccessStep
bookingResult={bookingResult}
event={event}
locale={locale}
t={t}
formatDate={formatDate}
fmtTime={fmtTime}
/>
);
}
return (
<BookingFormStep
event={event}
locale={locale}
t={t}
spotsLeft={spotsLeft}
isSoldOut={isSoldOut}
ticketQuantity={ticketQuantity}
formData={formData}
setFormData={setFormData}
errors={errors}
attendees={attendees}
setAttendees={setAttendees}
attendeeErrors={attendeeErrors}
setAttendeeErrors={setAttendeeErrors}
handleRucChange={handleRucChange}
handleRucBlur={handleRucBlur}
paymentMethods={paymentMethods}
agreedToTerms={agreedToTerms}
setAgreedToTerms={setAgreedToTerms}
termsError={termsError}
submitting={submitting}
onSubmit={handleSubmit}
formatDate={formatDate}
fmtTime={fmtTime}
/>
);
}