Backend and frontend updates: auth, email, payments, events, tickets; carrousel images; mobile event detail layout; i18n

This commit is contained in:
Michilis
2026-02-02 20:58:21 +00:00
parent bafd1425c4
commit 4a84ad22c7
44 changed files with 1323 additions and 472 deletions

View File

@@ -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>

View File

@@ -229,12 +229,15 @@ export default function BookingSuccessPage() {
{isPaid && (
<div className="mb-6">
<a
href={`/api/tickets/${ticketId}/pdf`}
href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${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' ? 'Descargar Ticket' : 'Download Ticket'}
{locale === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</a>
</div>
)}

View File

@@ -170,6 +170,20 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button>
</Link>
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
<a
href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`
}
download
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</Button>
</a>
)}
{ticket.invoice && (
<a
href={ticket.invoice.pdfUrl || '#'}

View File

@@ -14,6 +14,8 @@ import {
MapPinIcon,
UserGroupIcon,
ArrowLeftIcon,
MinusIcon,
PlusIcon,
} from '@heroicons/react/24/outline';
interface EventDetailClientProps {
@@ -25,6 +27,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const { t, locale } = useLanguage();
const [event, setEvent] = useState<Event>(initialEvent);
const [mounted, setMounted] = useState(false);
const [ticketQuantity, setTicketQuantity] = useState(1);
// Ensure consistent hydration by only rendering dynamic content after mount
useEffect(() => {
@@ -38,6 +41,17 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
.catch(console.error);
}, [eventId]);
// Max tickets is remaining capacity
const maxTickets = Math.max(1, event.availableSeats || 1);
const decreaseQuantity = () => {
setTicketQuantity(prev => Math.max(1, prev - 1));
};
const increaseQuantity = () => {
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
@@ -60,6 +74,92 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
// Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => (
<>
<div className="text-center mb-4">
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
<p className="text-4xl font-bold text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</p>
{event.price > 0 && (
<p className="text-xs text-gray-400 mt-1">
{locale === 'es' ? 'por persona' : 'per person'}
</p>
)}
</div>
{/* Ticket Quantity Selector */}
{canBook && !event.externalBookingEnabled && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 text-center mb-2">
{locale === 'es' ? 'Cantidad de tickets' : 'Number of tickets'}
</label>
<div className="flex items-center justify-center gap-4">
<button
type="button"
onClick={decreaseQuantity}
disabled={ticketQuantity <= 1}
className="w-10 h-10 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-primary-yellow hover:bg-primary-yellow/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<MinusIcon className="w-5 h-5" />
</button>
<span className="text-2xl font-bold w-12 text-center">{ticketQuantity}</span>
<button
type="button"
onClick={increaseQuantity}
disabled={ticketQuantity >= maxTickets}
className="w-10 h-10 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-primary-yellow hover:bg-primary-yellow/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<PlusIcon className="w-5 h-5" />
</button>
</div>
{ticketQuantity > 1 && event.price > 0 && (
<p className="text-center text-sm text-gray-600 mt-2">
{locale === 'es' ? 'Total' : 'Total'}: <span className="font-bold">{formatPrice(event.price * ticketQuantity, event.currency)}</span>
</p>
)}
</div>
)}
{canBook ? (
event.externalBookingEnabled && event.externalBookingUrl ? (
<a
href={event.externalBookingUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</a>
) : (
<Link href={`/book/${event.id}?qty=${ticketQuantity}`}>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</Link>
)
) : (
<Button className="w-full" size="lg" disabled>
{isPastEvent
? t('events.details.eventEnded')
: isSoldOut
? t('events.details.soldOut')
: t('events.details.cancelled')}
</Button>
)}
{!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')}
</p>
)}
</>
);
return (
<div className="section-padding">
<div className="container-page">
@@ -73,157 +173,128 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Event Details */}
<div className="lg:col-span-2">
<div className="lg:col-span-2 space-y-6">
{/* Top section: Image + Event Info side by side on desktop */}
<Card className="overflow-hidden">
{/* Banner - LCP element, loaded with high priority */}
{/* Using unoptimized for backend-served images via /uploads/ rewrite */}
{event.bannerUrl ? (
<div className="relative h-64 w-full">
<Image
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 66vw"
priority
unoptimized
/>
</div>
) : (
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
</div>
)}
<div className="p-8">
<div className="flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold text-primary-dark" suppressHydrationWarning>
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
<div className="flex flex-col md:flex-row">
{/* Image - smaller on desktop, side by side */}
{event.bannerUrl ? (
<div className="relative md:w-2/5 flex-shrink-0 bg-gray-100">
<Image
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
width={400}
height={400}
className="w-full h-auto md:h-full object-cover"
sizes="(max-width: 768px) 100vw, 300px"
priority
unoptimized
/>
</div>
) : (
<div className="md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="flex items-start gap-3">
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.date')}</p>
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl"></span>
<div>
<p className="font-medium">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>
{formatTime(event.startDatetime)}
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
{/* Event title and key info */}
<div className="flex-1 p-6">
<div className="flex items-start justify-between gap-4 mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-primary-dark" suppressHydrationWarning>
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
<div className="flex-shrink-0">
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
</div>
{!event.externalBookingEnabled && (
<div className="space-y-4">
<div className="flex items-start gap-3">
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<CalendarIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
<p className="font-medium text-sm">{t('events.details.date')}</p>
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow text-lg"></span>
<div>
<p className="font-medium text-sm">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>
{formatTime(event.startDatetime)}
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
</p>
</div>
</div>
)}
</div>
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
</div>
{/* Social Sharing */}
<div className="mt-8 pt-8 border-t border-secondary-light-gray" suppressHydrationWarning>
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
<div className="flex items-start gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
)}
</div>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-start gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</Card>
{/* Mobile Booking Card - shown between event details and description on mobile */}
<Card className="p-6 lg:hidden">
<BookingCardContent />
</Card>
{/* Description section - separate card below */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
{/* Social Sharing */}
<div className="mt-8 pt-6 border-t border-secondary-light-gray" suppressHydrationWarning>
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
</div>
</Card>
</div>
{/* Booking Card */}
<div className="lg:col-span-1">
{/* Desktop Booking Card - hidden on mobile, shown in sidebar on desktop */}
<div className="hidden lg:block lg:col-span-1">
<Card className="p-6 sticky top-24">
<div className="text-center mb-6">
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
<p className="text-4xl font-bold text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</p>
</div>
{canBook ? (
event.externalBookingEnabled && event.externalBookingUrl ? (
<a
href={event.externalBookingUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</a>
) : (
<Link href={`/book/${event.id}`}>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</Link>
)
) : (
<Button className="w-full" size="lg" disabled>
{isPastEvent
? t('events.details.eventEnded')
: isSoldOut
? t('events.details.soldOut')
: t('events.details.cancelled')}
</Button>
)}
{!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')}
</p>
)}
<BookingCardContent />
</Card>
</div>
</div>