- Backend: use calculateAvailableSeats so availableSeats is never negative - Backend: reject public booking when confirmed >= capacity; admin create/manual bypass capacity - Frontend: spotsLeft = max(0, capacity - bookedCount), isSoldOut when bookedCount >= capacity - Frontend: sold-out redirect on booking page, cap quantity by spotsLeft, never show negative Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import Image from 'next/image';
|
|
import { useLanguage } from '@/context/LanguageContext';
|
|
import { eventsApi, Event } from '@/lib/api';
|
|
import { formatPrice } from '@/lib/utils';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import ShareButtons from '@/components/ShareButtons';
|
|
import {
|
|
CalendarIcon,
|
|
MapPinIcon,
|
|
UserGroupIcon,
|
|
ArrowLeftIcon,
|
|
MinusIcon,
|
|
PlusIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
|
|
interface EventDetailClientProps {
|
|
eventId: string;
|
|
initialEvent: Event;
|
|
}
|
|
|
|
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
|
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(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// Refresh event data on client for real-time availability
|
|
useEffect(() => {
|
|
eventsApi.getById(eventId)
|
|
.then(({ event }) => setEvent(event))
|
|
.catch(console.error);
|
|
}, [eventId]);
|
|
|
|
// Spots left: never negative; sold out when confirmed >= capacity
|
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
|
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
|
const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft);
|
|
|
|
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',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
const formatTime = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
const isCancelled = event.status === 'cancelled';
|
|
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
|
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">
|
|
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div className="section-padding">
|
|
<div className="container-page">
|
|
<Link
|
|
href="/events"
|
|
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
|
>
|
|
<ArrowLeftIcon className="w-4 h-4" />
|
|
{t('common.back')}
|
|
</Link>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Event Details */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Top section: Image + Event Info side by side on desktop */}
|
|
<Card className="overflow-hidden">
|
|
<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>
|
|
)}
|
|
|
|
{/* 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>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-start gap-3">
|
|
<CalendarIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<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 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">
|
|
{spotsLeft} / {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>
|
|
|
|
{/* 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">
|
|
<BookingCardContent />
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|