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>
510 lines
18 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|