Files
Spanglish/frontend/src/app/(public)/book/[eventId]/page.tsx
Michilis 0c142884c7 feat: add featured event with automatic fallback
- Add featured_event_id to site_settings (schema + migration)
- Backend: featured event logic in /events/next/upcoming with auto-unset when event ends
- Site settings: PUT supports featuredEventId, add PUT /featured-event for admin
- Admin events: Set as featured checkbox in editor, star toggle in list, featured badge
- Admin settings: Featured Event section with current event and remove/change links
- API: siteSettingsApi.setFeaturedEvent(), Event.isFeatured, SiteSettings.featuredEventId
- Homepage/linktree unchanged: still use getNextUpcoming (now returns featured or fallback)
2026-02-03 19:24:00 +00:00

1345 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { QRCodeSVG } from 'qrcode.react';
import {
CalendarIcon,
MapPinIcon,
UserGroupIcon,
CurrencyDollarIcon,
ArrowLeftIcon,
CheckCircleIcon,
CreditCardIcon,
BanknotesIcon,
BoltIcon,
TicketIcon,
ClipboardDocumentIcon,
BuildingLibraryIcon,
ClockIcon,
ArrowTopRightOnSquareIcon,
UserIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
// Attendee info for each ticket
interface AttendeeInfo {
firstName: string;
lastName: string;
}
type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
interface BookingFormData {
firstName: string;
lastName: string;
email: string;
phone: string;
preferredLanguage: 'en' | 'es';
paymentMethod: PaymentMethod;
ruc: string;
}
interface LightningInvoice {
paymentHash: string;
paymentRequest: string; // BOLT11 invoice
amount: number; // Amount in satoshis
fiatAmount?: number; // Original fiat amount
fiatCurrency?: string; // Original fiat currency
expiry?: string;
}
interface BookingResult {
ticketId: string;
ticketIds?: string[]; // For multi-ticket bookings
bookingId?: string;
qrCode: string;
qrCodes?: string[]; // For multi-ticket bookings
paymentMethod: PaymentMethod;
lightningInvoice?: LightningInvoice;
ticketCount?: number;
}
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<'form' | 'paying' | 'manual_payment' | 'pending_approval' | 'success'>('form');
const [submitting, setSubmitting] = useState(false);
const [bookingResult, setBookingResult] = useState<BookingResult | null>(null);
const [paymentPending, setPaymentPending] = useState(false);
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: '',
preferredLanguage: locale as 'en' | 'es',
paymentMethod: 'cash',
ruc: '',
});
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
const rucPattern = /^\d{6,10}$/;
// Format RUC input: digits only, max 10
const formatRuc = (value: string): string => {
const digits = value.replace(/\D/g, '').slice(0, 10);
return digits;
};
// 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: 610 digits)
const handleRucBlur = () => {
if (!formData.ruc) return;
const digits = formData.ruc.replace(/\D/g, '');
if (digits.length > 0 && !rucPattern.test(digits)) {
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 || eventRes.event.status !== 'published') {
toast.error('Event is not available for booking');
router.push('/events');
return;
}
// Redirect to external booking if enabled
if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) {
window.location.href = eventRes.event.externalBookingUrl;
return;
}
setEvent(eventRes.event);
setPaymentConfig(paymentRes.paymentOptions);
// Set default payment method based on what's enabled
const config = paymentRes.paymentOptions;
if (config.lightningEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'lightning' }));
} else if (config.cashEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'cash' }));
} else if (config.bankTransferEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'bank_transfer' }));
} else if (config.tpagoEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'tpago' }));
}
})
.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 || '',
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
ruc: prev.ruc || user.rucNumber || '',
};
});
}
}, [user]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
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 - 610 digits if filled)
if (formData.ruc.trim()) {
const digits = formData.ruc.replace(/\D/g, '');
if (!/^\d{6,10}$/.test(digits)) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
}
}
// 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);
return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0;
};
// Connect to SSE for real-time payment updates
const connectPaymentStream = (ticketId: string) => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
const eventSource = new EventSource(`${apiUrl}/api/lnbits/stream/${ticketId}`);
eventSource.addEventListener('payment', (event) => {
try {
const data = JSON.parse(event.data);
console.log('Payment event:', data);
if (data.type === 'paid') {
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
setPaymentPending(false);
setStep('success');
eventSource.close();
} else if (data.type === 'expired') {
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
setPaymentPending(false);
eventSource.close();
} else if (data.type === 'already_paid') {
setPaymentPending(false);
setStep('success');
eventSource.close();
}
} catch (e) {
console.error('Error parsing payment event:', e);
}
});
eventSource.onerror = (error) => {
console.error('SSE error:', error);
// Fallback to polling if SSE fails
eventSource.close();
fallbackPoll(ticketId);
};
return eventSource;
};
// Fallback polling if SSE is not available
const fallbackPoll = async (ticketId: string) => {
const maxAttempts = 60;
let attempts = 0;
const poll = async () => {
attempts++;
try {
const status = await ticketsApi.checkPaymentStatus(ticketId);
if (status.isPaid) {
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
setPaymentPending(false);
setStep('success');
return;
}
} catch (error) {
console.error('Error checking payment status:', error);
}
if (attempts < maxAttempts && paymentPending) {
setTimeout(poll, 5000);
}
};
poll();
};
// Copy invoice to clipboard
const copyInvoiceToClipboard = (invoice: string) => {
navigator.clipboard.writeText(invoice).then(() => {
toast.success(locale === 'es' ? '¡Copiado!' : 'Copied!');
}).catch(() => {
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
});
};
// Truncate invoice for display
const truncateInvoice = (invoice: string, chars: number = 20) => {
if (invoice.length <= chars * 2) return invoice;
return `${invoice.slice(0, chars)}...${invoice.slice(-chars)}`;
};
// 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 (!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: formData.preferredLanguage,
paymentMethod: formData.paymentMethod,
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
// 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');
setPaymentPending(true);
// Connect to SSE for real-time payment updates
connectPaymentStream(primaryTicket.id);
} 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,
});
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,
ticketCount,
});
setStep('success');
toast.success(t('booking.success.message'));
}
} catch (error: any) {
toast.error(error.message || t('booking.form.errors.bookingFailed'));
} finally {
setSubmitting(false);
}
};
// Build payment methods list based on configuration
const paymentMethods: { id: PaymentMethod; icon: typeof CreditCardIcon; label: string; description: string; badge?: string }[] = [];
if (paymentConfig?.lightningEnabled) {
paymentMethods.push({
id: 'lightning',
icon: BoltIcon,
label: 'Bitcoin Lightning',
description: locale === 'es' ? 'Pago instantáneo con Bitcoin' : 'Instant payment with Bitcoin',
badge: locale === 'es' ? 'Instantáneo' : 'Instant',
});
}
if (paymentConfig?.tpagoEnabled) {
paymentMethods.push({
id: 'tpago',
icon: CreditCardIcon,
label: locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card',
description: locale === 'es' ? 'Paga con tarjeta de crédito o débito' : 'Pay with credit or debit card',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
if (paymentConfig?.bankTransferEnabled) {
paymentMethods.push({
id: 'bank_transfer',
icon: BuildingLibraryIcon,
label: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
description: locale === 'es' ? 'Transferencia bancaria local' : 'Local bank transfer',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
if (paymentConfig?.cashEnabled) {
paymentMethods.push({
id: 'cash',
icon: BanknotesIcon,
label: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
description: locale === 'es' ? 'Paga cuando llegues al evento' : 'Pay when you arrive at the event',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
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 isSoldOut = event.availableSeats === 0;
// Get title and description based on payment method
const getSuccessContent = () => {
if (bookingResult?.paymentMethod === 'cash') {
return {
title: locale === 'es' ? '¡Reserva Recibida!' : 'Reservation Received!',
description: locale === 'es'
? 'Tu lugar está reservado. El pago se realizará en el evento.'
: 'Your spot is reserved. Payment will be collected at the event.',
iconColor: 'bg-yellow-100',
iconTextColor: 'text-yellow-600',
};
}
if (bookingResult?.paymentMethod === 'lightning') {
// For Lightning, if we're on success step, payment was confirmed
return {
title: locale === 'es' ? '¡Pago Confirmado!' : 'Payment Confirmed!',
description: locale === 'es'
? '¡Tu reserva está confirmada! Te esperamos en el evento.'
: 'Your booking is confirmed! See you at the event.',
iconColor: 'bg-green-100',
iconTextColor: 'text-green-600',
};
}
return {
title: t('booking.success.title'),
description: t('booking.success.description'),
iconColor: 'bg-green-100',
iconTextColor: 'text-green-600',
};
};
// Paying step - waiting for Lightning payment (compact design)
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
const invoice = bookingResult.lightningInvoice;
return (
<div className="section-padding">
<div className="container-page max-w-md">
<Card className="p-6 text-center">
{/* Amount - prominent at top */}
<div className="mb-4">
{invoice.fiatAmount && invoice.fiatCurrency && (
<p className="text-2xl font-bold text-primary-dark">
{invoice.fiatAmount.toLocaleString()} {invoice.fiatCurrency}
</p>
)}
<p className="text-orange-600 font-medium">
{invoice.amount.toLocaleString()} sats
</p>
</div>
{/* QR Code - clickable to copy */}
<div
className="bg-white p-4 rounded-lg shadow-inner inline-block mb-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest)}
title={locale === 'es' ? 'Clic para copiar' : 'Click to copy'}
>
<QRCodeSVG
value={invoice.paymentRequest.toUpperCase()}
size={200}
level="M"
includeMargin={false}
/>
</div>
{/* Invoice string - truncated, clickable */}
<div
className="bg-secondary-gray rounded-lg p-3 mb-4 cursor-pointer hover:bg-gray-200 transition-colors"
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest)}
>
<p className="font-mono text-xs text-gray-600 flex items-center justify-center gap-2">
<ClipboardDocumentIcon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{truncateInvoice(invoice.paymentRequest, 16)}</span>
</p>
<p className="text-xs text-gray-400 mt-1">
{locale === 'es' ? 'Toca para copiar' : 'Tap to copy'}
</p>
</div>
{/* Open in Wallet - primary action */}
<a
href={`lightning:${invoice.paymentRequest}`}
className="inline-flex items-center justify-center gap-2 w-full px-6 py-3 bg-orange-500 text-white rounded-btn hover:bg-orange-600 transition-colors font-medium mb-4"
>
<BoltIcon className="w-5 h-5" />
{locale === 'es' ? 'Abrir en Billetera' : 'Open in Wallet'}
</a>
{/* Status indicator */}
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
<div className="animate-spin w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full" />
<span>{locale === 'es' ? 'Esperando pago...' : 'Waiting for payment...'}</span>
</div>
{/* Ticket reference - small */}
<p className="text-xs text-gray-400 mt-3">
{locale === 'es' ? 'Ref' : 'Ref'}: {bookingResult.qrCode}
</p>
</Card>
</div>
</div>
);
}
// Manual payment step - showing bank transfer details or TPago link
if (step === 'manual_payment' && bookingResult && paymentConfig) {
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
const isTpago = bookingResult.paymentMethod === 'tpago';
const ticketCount = bookingResult.ticketCount || 1;
const totalAmount = (event?.price || 0) * ticketCount;
return (
<div className="section-padding">
<div className="container-page max-w-xl">
<Card className="p-6">
<div className="text-center mb-6">
<div className={`w-16 h-16 rounded-full ${isBankTransfer ? 'bg-green-100' : 'bg-blue-100'} flex items-center justify-center mx-auto mb-4`}>
{isBankTransfer ? (
<BuildingLibraryIcon className="w-8 h-8 text-green-600" />
) : (
<CreditCardIcon className="w-8 h-8 text-blue-600" />
)}
</div>
<h1 className="text-xl font-bold text-primary-dark mb-2">
{locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Sigue las instrucciones para completar tu pago'
: 'Follow the instructions to complete your payment'}
</p>
</div>
{/* Amount to pay */}
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-center">
<p className="text-sm text-gray-500 mb-1">
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p>
<p className="text-2xl font-bold text-primary-dark">
{event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
</p>
{ticketCount > 1 && (
<p className="text-sm text-gray-500 mt-1">
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
</p>
)}
</div>
{/* Bank Transfer Details */}
{isBankTransfer && (
<div className="space-y-4 mb-6">
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Datos Bancarios' : 'Bank Details'}
</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
{paymentConfig.bankName && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Banco' : 'Bank'}:</span>
<span className="font-medium">{paymentConfig.bankName}</span>
</div>
)}
{paymentConfig.bankAccountHolder && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Titular' : 'Account Holder'}:</span>
<span className="font-medium">{paymentConfig.bankAccountHolder}</span>
</div>
)}
{paymentConfig.bankAccountNumber && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}:</span>
<span className="font-medium font-mono">{paymentConfig.bankAccountNumber}</span>
</div>
)}
{paymentConfig.bankAlias && (
<div className="flex justify-between">
<span className="text-gray-600">Alias:</span>
<span className="font-medium">{paymentConfig.bankAlias}</span>
</div>
)}
{paymentConfig.bankPhone && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Teléfono' : 'Phone'}:</span>
<span className="font-medium">{paymentConfig.bankPhone}</span>
</div>
)}
</div>
{(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && (
<p className="text-sm text-gray-600">
{locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes}
</p>
)}
</div>
)}
{/* TPago Link */}
{isTpago && (
<div className="space-y-4 mb-6">
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
</h3>
{paymentConfig.tpagoLink && (
<a
href={paymentConfig.tpagoLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
>
<ArrowTopRightOnSquareIcon className="w-5 h-5" />
{locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'}
</a>
)}
{(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && (
<p className="text-sm text-gray-600">
{locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions}
</p>
)}
</div>
)}
{/* Reference */}
<div className="bg-gray-100 rounded-lg p-3 mb-6">
<p className="text-xs text-gray-500 mb-1">
{locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'}
</p>
<p className="font-mono font-bold text-lg">{bookingResult.qrCode}</p>
</div>
{/* Manual verification notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
</p>
<p className="text-blue-700">
{locale === 'es'
? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.'
: 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'}
</p>
</div>
</div>
</div>
{/* Paid under different name option */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={paidUnderDifferentName}
onChange={(e) => {
setPaidUnderDifferentName(e.target.checked);
if (!e.target.checked) setPayerName('');
}}
className="mt-1 w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
/>
<div>
<span className="font-medium text-gray-700">
{locale === 'es'
? 'El pago está a nombre de otra persona'
: 'The payment is under another person\'s name'}
</span>
<p className="text-xs text-gray-500 mt-1">
{locale === 'es'
? 'Marcá esta opción si el pago fue realizado por un familiar o tercero.'
: 'Check this option if the payment was made by a family member or a third party.'}
</p>
</div>
</label>
{paidUnderDifferentName && (
<div className="mt-3 pl-7">
<Input
label={locale === 'es' ? 'Nombre del pagador' : 'Payer name'}
value={payerName}
onChange={(e) => setPayerName(e.target.value)}
placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'}
required
/>
</div>
)}
</div>
{/* Warning before I Have Paid button */}
<p className="text-sm text-center text-amber-700 font-medium mb-3">
{locale === 'es'
? 'Solo haz clic aquí después de haber completado el pago.'
: 'Only click this after you have actually completed the payment.'}
</p>
{/* I Have Paid Button */}
<Button
onClick={handleMarkPaymentSent}
isLoading={markingPaid}
size="lg"
className="w-full"
disabled={paidUnderDifferentName && !payerName.trim()}
>
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
</Button>
<p className="text-xs text-center text-gray-500 mt-4">
{locale === 'es'
? 'Tu reserva será confirmada una vez que verifiquemos el pago'
: 'Your booking will be confirmed once we verify the payment'}
</p>
</Card>
</div>
</div>
);
}
// Pending approval step - user has marked payment as sent
if (step === 'pending_approval' && bookingResult) {
return (
<div className="section-padding">
<div className="container-page max-w-xl">
<Card className="p-8 text-center">
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mx-auto mb-6">
<ClockIcon className="w-10 h-10 text-yellow-600" />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'}
</h1>
<p className="text-gray-600 mb-6">
{locale === 'es'
? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.'
: 'We are verifying your payment. You will receive a confirmation email once approved.'}
</p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
<div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
</div>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {event && formatTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
{locale === 'es'
? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.'
: 'Payment verification may take up to 24 business hours. Please check your email regularly.'}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
</Link>
<Link href="/">
<Button>{t('booking.success.backHome')}</Button>
</Link>
</div>
</Card>
</div>
</div>
);
}
// Success step
if (step === 'success' && bookingResult) {
const successContent = getSuccessContent();
return (
<div className="section-padding">
<div className="container-page max-w-2xl">
<Card className="p-8 text-center">
<div className={`w-16 h-16 rounded-full ${successContent.iconColor} flex items-center justify-center mx-auto mb-6`}>
<CheckCircleIcon className={`w-10 h-10 ${successContent.iconTextColor}`} />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{successContent.title}
</h1>
<p className="text-gray-600 mb-6">
{successContent.description}
</p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
{/* Multi-ticket indicator */}
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<div className="mb-4 pb-4 border-b border-gray-300">
<p className="text-lg font-semibold text-primary-dark">
{locale === 'es'
? `${bookingResult.ticketCount} tickets reservados`
: `${bookingResult.ticketCount} tickets booked`}
</p>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Cada asistente recibirá su propio código QR'
: 'Each attendee will receive their own QR code'}
</p>
</div>
)}
<div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
+{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}
</span>
)}
</div>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {formatTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
</div>
</div>
{bookingResult.paymentMethod === 'cash' && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
<strong>{t('booking.success.cashNote')}:</strong> {t('booking.success.cashDescription')}
</p>
</div>
)}
{bookingResult.paymentMethod === 'bancard' && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-800 text-sm">
{t('booking.success.cardNote')}
</p>
</div>
)}
{bookingResult.paymentMethod === 'lightning' && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800 text-sm flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5" />
{locale === 'es'
? '¡Pago con Bitcoin Lightning recibido exitosamente!'
: 'Bitcoin Lightning payment received successfully!'}
</p>
</div>
)}
<p className="text-sm text-gray-500 mb-6">
{t('booking.success.emailSent')}
</p>
{/* Download Ticket Button - only for instant confirmation (Lightning) */}
{bookingResult.paymentMethod === 'lightning' && (
<div className="mb-6">
<a
href={bookingResult.bookingId
? `/api/tickets/booking/${bookingResult.bookingId}/pdf`
: `/api/tickets/${bookingResult.ticketId}/pdf`
}
download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
<ArrowDownTrayIcon className="w-5 h-5" />
{locale === 'es'
? (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Descargar Tickets' : 'Descargar Ticket')
: (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Download Tickets' : 'Download Ticket')}
</a>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
</Link>
<Link href="/">
<Button>{t('booking.success.backHome')}</Button>
</Link>
</div>
</Card>
</div>
</div>
);
}
return (
<div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl">
<Link
href={`/events/${event.id}`}
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
{/* Event Summary - Always Visible */}
<Card className="mb-6 overflow-hidden">
<div className="bg-primary-yellow/20 p-4 border-b border-primary-yellow/30">
<h2 className="font-bold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h2>
</div>
<div className="p-4 space-y-2 text-sm">
<div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(event.startDatetime)} {formatTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
<span>{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
</div>
)}
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
<span className="font-bold text-lg">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
{event.price > 0 && (
<span className="text-gray-400 text-sm">
{locale === 'es' ? 'por persona' : 'per person'}
</span>
)}
</div>
{/* Ticket quantity and total */}
{ticketQuantity > 1 && (
<div className="mt-3 pt-3 border-t border-secondary-light-gray">
<div className="flex items-center justify-between">
<span className="text-gray-600">
{locale === 'es' ? 'Tickets' : 'Tickets'}: <span className="font-semibold">{ticketQuantity}</span>
</span>
<span className="font-bold text-lg text-primary-dark">
{locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)}
</span>
</div>
</div>
)}
</div>
</Card>
{isSoldOut ? (
<Card className="p-8 text-center">
<UserGroupIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-700">{t('events.details.soldOut')}</h2>
<p className="text-gray-500 mt-2">{t('booking.form.soldOutMessage')}</p>
</Card>
) : (
<form onSubmit={handleSubmit}>
{/* User Information Section */}
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
{attendees.length > 0 && (
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
1
</span>
)}
{t('booking.form.personalInfo')}
{attendees.length > 0 && (
<span className="text-sm font-normal text-gray-500">
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
</span>
)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
placeholder={t('booking.form.firstNamePlaceholder')}
error={errors.firstName}
required
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
placeholder={t('booking.form.lastNamePlaceholder')}
error={errors.lastName}
/>
</div>
</div>
<div>
<Input
label={t('booking.form.email')}
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder={t('booking.form.emailPlaceholder')}
error={errors.email}
required
/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.phone')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder={t('booking.form.phonePlaceholder')}
error={errors.phone}
/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.ruc')}
</label>
<span className="text-xs text-gray-400">
{t('booking.form.rucOptional')}
</span>
</div>
<Input
value={formData.ruc}
onChange={handleRucChange}
onBlur={handleRucBlur}
placeholder={t('booking.form.rucPlaceholder')}
error={errors.ruc}
inputMode="numeric"
maxLength={10}
aria-label={t('booking.form.ruc')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('booking.form.preferredLanguage')}
</label>
<select
value={formData.preferredLanguage}
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
</Card>
{/* Additional Attendees Section (for multi-ticket bookings) */}
{attendees.length > 0 && (
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
<UserIcon className="w-5 h-5 text-primary-yellow" />
{locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{locale === 'es'
? 'Ingresa el nombre de cada asistente adicional. Cada persona recibirá su propio ticket.'
: 'Enter the name for each additional attendee. Each person will receive their own ticket.'}
</p>
<div className="space-y-4">
{attendees.map((attendee, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
{index + 2}
</span>
<span className="font-medium text-gray-700">
{locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={attendee.firstName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].firstName = e.target.value;
setAttendees(newAttendees);
if (attendeeErrors[index]) {
const newErrors = { ...attendeeErrors };
delete newErrors[index];
setAttendeeErrors(newErrors);
}
}}
placeholder={t('booking.form.firstNamePlaceholder')}
error={attendeeErrors[index]}
required
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={attendee.lastName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].lastName = e.target.value;
setAttendees(newAttendees);
}}
placeholder={t('booking.form.lastNamePlaceholder')}
/>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Payment Selection Section */}
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark">
{t('booking.form.paymentMethod')}
</h3>
<div className="space-y-3">
{paymentMethods.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{locale === 'es'
? 'No hay métodos de pago disponibles para este evento.'
: 'No payment methods available for this event.'}
</div>
) : (
<>
{paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
formData.paymentMethod === method.id
? 'border-primary-yellow bg-primary-yellow/10'
: 'border-secondary-light-gray hover:border-gray-300'
}`}
>
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
formData.paymentMethod === method.id
? 'bg-primary-yellow'
: 'bg-gray-100'
}`}>
<method.icon className={`w-5 h-5 ${
formData.paymentMethod === method.id
? 'text-primary-dark'
: 'text-gray-500'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-primary-dark">{method.label}</p>
{method.badge && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
method.badge === 'Instant' || method.badge === 'Instantáneo'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{method.badge}
</span>
)}
</div>
<p className="text-sm text-gray-500">{method.description}</p>
</div>
{formData.paymentMethod === method.id && (
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
)}
</button>
))}
</>
)}
</div>
</Card>
{/* Submit Button */}
<Button
type="submit"
size="lg"
className="w-full"
isLoading={submitting}
disabled={paymentMethods.length === 0}
>
{formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot')
: formData.paymentMethod === 'lightning'
? t('booking.form.proceedPayment')
: 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>
</div>
);
}