Add per-event finance, event team permissions and public door sales state.
Event finance - Finance tab on the event page: P&L summary with a revenue-to-result waterfall, costs and other income in one ledger, and the partner split with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close, Finalized) with what is left to do; closing the books goes through a checklist dialog that freezes the numbers. - Expense modal shows only the fields each calculation type needs, a live preview with the event's real counts, and a category suggested from the description. Date inputs follow the UI language. - Global Finance page: profit per event with outliers clipped and labelled, and a "Ready to close" list of past events whose books are still open. - Calculation service (integer PYG, basis points) with pinned regression scenarios; PYG formatting centralised in lib/money with locale-aware separators. Event team permissions - Per-event members with role presets and requireEventPermission; the header stat "Confirmed" is relabelled "Not checked in yet", which is what it counts. Public sales state - One sales state (online, door, sold out, ended, external, cancelled) for the event page, listings and JSON-LD, with the door price and tenders shown only while people can still pay at the door. Frontend unit tests run with vitest (npm test in frontend/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b51c1360e2
commit
e203fb6c74
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } 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, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords } from '@/lib/utils';
|
||||
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords, nextSalesStateChangeMs } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
@@ -24,6 +24,10 @@ interface EventDetailClientProps {
|
||||
}
|
||||
|
||||
const MAX_TICKETS_PER_PERSON = 5;
|
||||
// setTimeout overflows past ~24.8 days; farther flips are caught on a later visit.
|
||||
const MAX_TIMER_MS = 2_147_483_647;
|
||||
// Display order of door tenders ("card" is the POS terminal; never a provider name).
|
||||
const DOOR_METHOD_ORDER = ['cash', 'pos', 'bitcoin', 'transfer'] as const;
|
||||
|
||||
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
||||
const { t, locale } = useLanguage();
|
||||
@@ -36,13 +40,50 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Refresh event data on client for real-time availability
|
||||
useEffect(() => {
|
||||
// Server clock minus ours, so the refresh below fires on the server's schedule.
|
||||
const [clockOffsetMs, setClockOffsetMs] = useState(0);
|
||||
|
||||
const refreshEvent = useCallback(() => {
|
||||
const sentAt = Date.now();
|
||||
eventsApi.getById(eventId)
|
||||
.then(({ event }) => setEvent(event))
|
||||
.then(({ event, serverTime }) => {
|
||||
setEvent(event);
|
||||
if (serverTime) {
|
||||
const receivedAt = Date.now();
|
||||
setClockOffsetMs(parseDate(serverTime).getTime() - (sentAt + receivedAt) / 2);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [eventId]);
|
||||
|
||||
// Refresh event data on client for real-time availability. The server-rendered
|
||||
// copy can be up to a minute old (fetch revalidate), so this also corrects a
|
||||
// stale sales state right after load.
|
||||
useEffect(() => {
|
||||
refreshEvent();
|
||||
}, [refreshEvent]);
|
||||
|
||||
// salesState flips on the clock (online → door at presaleClosesAt, door →
|
||||
// ended at the end time) without any edit to the event: refetch at that moment
|
||||
// so an open page stops offering online booking. Overdue flips retry after 3s.
|
||||
useEffect(() => {
|
||||
const changeAt = nextSalesStateChangeMs(event);
|
||||
if (changeAt === null) return;
|
||||
const delay = Math.max(changeAt - (Date.now() + clockOffsetMs) + 1000, 3000);
|
||||
if (delay > MAX_TIMER_MS) return;
|
||||
const timer = setTimeout(refreshEvent, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [event, clockOffsetMs, refreshEvent]);
|
||||
|
||||
// Phones suspend timers in background tabs; catch up when the page is shown again.
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') refreshEvent();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, [refreshEvent]);
|
||||
|
||||
// Server-authoritative availability (paid + claimed seats count; abandoned
|
||||
// pending bookings don't) — matches the booking API's sold-out check exactly.
|
||||
const spotsLeft = eventSpotsLeft(event);
|
||||
@@ -67,18 +108,78 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
// Server-computed sales state (backend lib/salesState.ts). External booking
|
||||
// events, and any cached copy from before salesState existed, keep the
|
||||
// original client-side rules below.
|
||||
const salesState = event.salesState;
|
||||
const legacyRules = !salesState || salesState === 'external';
|
||||
const isDoorSales = salesState === 'door' && typeof event.doorPrice === 'number';
|
||||
const hasEnded = salesState === 'ended';
|
||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
||||
const isPastEvent = legacyRules ? (mounted ? new Date(event.startDatetime) < new Date() : false) : hasEnded;
|
||||
// Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent
|
||||
const presaleClosed = mounted ? isPresaleClosed(event) : false;
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted');
|
||||
const presaleClosed = legacyRules ? (mounted ? isPresaleClosed(event) : false) : salesState === 'door';
|
||||
const canBook = legacyRules
|
||||
? !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted')
|
||||
: salesState === 'online';
|
||||
// Effective lead time (event override or site default), derived from the server cutoff
|
||||
const presaleLeadMinutes = event.presaleClosesAt
|
||||
? Math.max(0, Math.round((parseDate(event.startDatetime).getTime() - parseDate(event.presaleClosesAt).getTime()) / 60_000))
|
||||
: null;
|
||||
|
||||
const spotsLeftText = spotsLeft === 1
|
||||
? t('events.door.spotsLeftOne')
|
||||
: t('events.door.spotsLeft', { n: spotsLeft });
|
||||
const doorMethodsText = DOOR_METHOD_ORDER
|
||||
.filter((method) => event.doorPaymentMethods?.includes(method))
|
||||
.map((method) => t(`events.door.methods.${method}`))
|
||||
.join(', ');
|
||||
|
||||
// Online sales closed but seats left: people can still come and pay at the
|
||||
// door. Informational only — there is nothing to click.
|
||||
const DoorSalesBlock = () => {
|
||||
const doorPrice = event.doorPrice ?? event.price;
|
||||
return (
|
||||
<div className="rounded-card bg-primary-yellow/15 border border-primary-yellow/40 px-5 py-6 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
{t('events.door.onlineClosed')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-primary-dark">
|
||||
{t('events.door.headline')}
|
||||
</p>
|
||||
<p className="mt-4 text-4xl font-bold text-primary-dark">
|
||||
{doorPrice === 0 ? t('events.details.free') : formatPrice(doorPrice, event.currency)}
|
||||
</p>
|
||||
{doorPrice > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">{t('events.door.perPerson')}</p>
|
||||
)}
|
||||
{doorPrice > event.price && event.price > 0 && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{t('events.door.onlinePriceWas', { price: formatPrice(event.price, event.currency) })}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-sm font-medium text-primary-dark">{spotsLeftText}</p>
|
||||
{doorMethodsText && (
|
||||
<p className="mt-1 text-xs text-gray-500">{t('events.door.accepts', { methods: doorMethodsText })}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500" suppressHydrationWarning>
|
||||
{t('events.door.doorsOpen', { time: fmtTime(event.startDatetime) })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Booking card content - reused for mobile and desktop positions
|
||||
const BookingCardContent = () => (
|
||||
const BookingCardContent = () => {
|
||||
if (isDoorSales) return <DoorSalesBlock />;
|
||||
if (hasEnded) {
|
||||
return (
|
||||
<div className="rounded-card bg-secondary-gray px-5 py-6 text-center">
|
||||
<p className="text-lg font-semibold text-primary-dark">{t('events.details.eventHasEnded')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||
@@ -173,7 +274,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
@@ -272,7 +374,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
<div>
|
||||
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
||||
<p className="text-gray-600">
|
||||
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
{isDoorSales
|
||||
? t('events.door.spotsLeftAtDoor', { n: spotsLeft })
|
||||
: `${spotsLeft} / ${event.capacity} ${t('events.details.spotsLeft')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound, permanentRedirect } from 'next/navigation';
|
||||
import EventDetailClient from './EventDetailClient';
|
||||
import type { EventSalesState } from '@/lib/api';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -26,6 +27,7 @@ interface Event {
|
||||
availableSeats?: number;
|
||||
bookedCount?: number;
|
||||
presaleClosesAt?: string | null;
|
||||
salesState?: EventSalesState;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -114,19 +116,23 @@ function generateEventJsonLd(event: Event) {
|
||||
name: 'Spanglish',
|
||||
url: siteUrl,
|
||||
},
|
||||
// The offer is always the online price; the door price is never published
|
||||
// here. In the `door` state seats can still be bought (at the door), so it
|
||||
// stays InStock and drops the pre-sale validThrough, which is in the past.
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: event.price,
|
||||
priceCurrency: event.currency,
|
||||
availability:
|
||||
event.salesState === 'sold_out' ||
|
||||
(typeof event.availableSeats === 'number'
|
||||
? event.availableSeats
|
||||
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) <= 0
|
||||
? 'https://schema.org/SoldOut'
|
||||
: 'https://schema.org/InStock',
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
validFrom: new Date().toISOString(),
|
||||
...(event.presaleClosesAt ? { validThrough: event.presaleClosesAt } : {}),
|
||||
...(event.presaleClosesAt && event.salesState !== 'door' ? { validThrough: event.presaleClosesAt } : {}),
|
||||
},
|
||||
image: event.bannerUrl
|
||||
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
||||
|
||||
Reference in New Issue
Block a user