Fix event capacity: no negative availability, sold-out enforcement, admin override

- 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>
This commit is contained in:
Michilis
2026-02-12 03:01:58 +00:00
parent 8315029091
commit fe75912f23
8 changed files with 61 additions and 68 deletions

View File

@@ -150,14 +150,32 @@ export default function BookingPage() {
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
@@ -513,7 +531,8 @@ export default function BookingPage() {
return null;
}
const isSoldOut = event.availableSeats === 0;
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 = () => {
@@ -1035,7 +1054,7 @@ export default function BookingPage() {
{!event.externalBookingEnabled && (
<div className="flex items-center gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
</div>
)}
<div className="flex items-center gap-3">

View File

@@ -41,8 +41,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
.catch(console.error);
}, [eventId]);
// Max tickets is remaining capacity
const maxTickets = Math.max(1, event.availableSeats || 1);
// 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));
@@ -68,7 +70,6 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
});
};
const isSoldOut = event.availableSeats === 0;
const isCancelled = event.status === 'cancelled';
// Only calculate isPastEvent after mount to avoid hydration mismatch
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
@@ -154,7 +155,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
{!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')}
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
</p>
)}
</>
@@ -257,7 +258,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<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')}
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>

View File

@@ -118,7 +118,7 @@ function generateEventJsonLd(event: Event) {
'@type': 'Offer',
price: event.price,
priceCurrency: event.currency,
availability: event.availableSeats && event.availableSeats > 0
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`,

View File

@@ -140,7 +140,7 @@ export default function EventsPage() {
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}