- Backend: add 'unlisted' to schema enum and Zod validation; allow booking for unlisted events - Frontend: Event type and guards updated; unlisted events bookable, excluded from public listing/sitemap - Admin: badge, status dropdown, Make Unlisted / Make Public / Unpublish actions; scanner/emails/tickets include unlisted Co-authored-by: Cursor <cursoragent@cursor.com>
1351 lines
56 KiB
TypeScript
1351 lines
56 KiB
TypeScript
'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, formatDateLong, formatTime } 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: 6–10 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 || !['published', 'unlisted'].includes(eventRes.event.status)) {
|
||
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;
|
||
}
|
||
|
||
const bookedCount = eventRes.event.bookedCount ?? 0;
|
||
const capacity = eventRes.event.capacity ?? 0;
|
||
const soldOut = bookedCount >= capacity;
|
||
if (soldOut) {
|
||
toast.error(t('events.details.soldOut'));
|
||
router.push(`/events/${eventRes.event.id}`);
|
||
return;
|
||
}
|
||
|
||
const spotsLeft = Math.max(0, capacity - bookedCount);
|
||
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);
|
||
|
||
// 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) => 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 - 6–10 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 / Tarjetas de Crédito' : 'TPago / Credit Cards',
|
||
description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
|
||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
||
});
|
||
}
|
||
|
||
if (paymentConfig?.bankTransferEnabled) {
|
||
paymentMethods.push({
|
||
id: 'bank_transfer',
|
||
icon: BuildingLibraryIcon,
|
||
label: locale === 'es' ? 'Transferencia Bancaria Local' : 'Local Bank Transfer',
|
||
description: locale === 'es' ? 'Pago por transferencia bancaria en Paraguay' : 'Pay via Paraguayan 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 spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||
|
||
// 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 && fmtTime(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> {fmtTime(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)} • {fmtTime(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>{spotsLeft} / {event.capacity} {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>
|
||
);
|
||
}
|