Backend and frontend updates: auth, email, payments, events, tickets; carrousel images; mobile event detail layout; i18n
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -26,9 +26,17 @@ import {
|
||||
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 {
|
||||
@@ -52,14 +60,19 @@ interface LightningInvoice {
|
||||
|
||||
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);
|
||||
@@ -71,6 +84,20 @@ export default function BookingPage() {
|
||||
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: '',
|
||||
@@ -228,6 +255,7 @@ export default function BookingPage() {
|
||||
|
||||
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');
|
||||
@@ -257,8 +285,18 @@ export default function BookingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
setAttendeeErrors(newAttendeeErrors);
|
||||
return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0;
|
||||
};
|
||||
|
||||
// Connect to SSE for real-time payment updates
|
||||
@@ -346,9 +384,20 @@ export default function BookingPage() {
|
||||
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);
|
||||
await ticketsApi.markPaymentSent(
|
||||
bookingResult.ticketId,
|
||||
paidUnderDifferentName ? payerName.trim() : undefined
|
||||
);
|
||||
setStep('pending_approval');
|
||||
toast.success(locale === 'es'
|
||||
? 'Pago marcado como enviado. Esperando aprobación.'
|
||||
@@ -366,6 +415,12 @@ export default function BookingPage() {
|
||||
|
||||
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,
|
||||
@@ -375,16 +430,24 @@ export default function BookingPage() {
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
paymentMethod: formData.paymentMethod,
|
||||
...(formData.ruc.trim() && { ruc: formData.ruc }),
|
||||
// Include attendees array for multi-ticket bookings
|
||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||
});
|
||||
|
||||
const { ticket, lightningInvoice } = response as any;
|
||||
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: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
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,
|
||||
@@ -399,21 +462,29 @@ export default function BookingPage() {
|
||||
setPaymentPending(true);
|
||||
|
||||
// Connect to SSE for real-time payment updates
|
||||
connectPaymentStream(ticket.id);
|
||||
connectPaymentStream(primaryTicket.id);
|
||||
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
|
||||
// Manual payment methods - show payment details
|
||||
setBookingResult({
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
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: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
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'));
|
||||
@@ -592,6 +663,8 @@ export default function BookingPage() {
|
||||
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">
|
||||
@@ -621,8 +694,13 @@ export default function BookingPage() {
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{event?.price !== undefined ? formatPrice(event.price, event.currency) : ''}
|
||||
{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 */}
|
||||
@@ -725,6 +803,45 @@ export default function BookingPage() {
|
||||
</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'
|
||||
@@ -738,6 +855,7 @@ export default function BookingPage() {
|
||||
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'}
|
||||
@@ -829,9 +947,30 @@ export default function BookingPage() {
|
||||
</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">
|
||||
@@ -873,6 +1012,25 @@ export default function BookingPage() {
|
||||
{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>
|
||||
@@ -927,7 +1085,25 @@ export default function BookingPage() {
|
||||
? 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>
|
||||
|
||||
@@ -941,8 +1117,18 @@ export default function BookingPage() {
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* User Information Section */}
|
||||
<Card className="mb-6 p-6">
|
||||
<h3 className="font-bold text-lg mb-4 text-primary-dark">
|
||||
<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">
|
||||
@@ -1040,6 +1226,74 @@ export default function BookingPage() {
|
||||
</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">
|
||||
@@ -1098,45 +1352,6 @@ export default function BookingPage() {
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Manual payment instructions - shown when TPago or Bank Transfer is selected */}
|
||||
{(formData.paymentMethod === 'tpago' || formData.paymentMethod === 'bank_transfer') && (
|
||||
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-sm text-amber-800">
|
||||
<p className="font-medium mb-1">
|
||||
{locale === 'es' ? 'Proceso de pago manual' : 'Manual payment process'}
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-amber-700">
|
||||
<li>
|
||||
{locale === 'es'
|
||||
? 'Por favor completa el pago primero.'
|
||||
: 'Please complete the payment first.'}
|
||||
</li>
|
||||
<li>
|
||||
{locale === 'es'
|
||||
? 'Después de pagar, haz clic en "Ya pagué" para notificarnos.'
|
||||
: 'After you have paid, click "I have paid" to notify us.'}
|
||||
</li>
|
||||
<li>
|
||||
{locale === 'es'
|
||||
? 'Nuestro equipo verificará el pago manualmente.'
|
||||
: 'Our team will manually verify the payment.'}
|
||||
</li>
|
||||
<li>
|
||||
{locale === 'es'
|
||||
? 'Una vez aprobado, recibirás un email confirmando tu reserva.'
|
||||
: 'Once approved, you will receive an email confirming your booking.'}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user