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
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { CalendarIcon, MapPinIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import type { MyEvent } from '@/lib/api';
|
||||
|
||||
/** Events the user was added to as staff, collaborator or co-manager. */
|
||||
export default function MyEventsTab({ events }: { events: MyEvent[] }) {
|
||||
const { t, locale } = useLanguage();
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-gray-500 py-8 text-center">{t('dashboard.myEvents.empty')}</p>;
|
||||
}
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{events.map(({ event, rolePreset }) => (
|
||||
<li key={event.id}>
|
||||
<Link
|
||||
href={`/dashboard/events/${event.id}`}
|
||||
className="flex items-center gap-4 rounded-card bg-white p-4 shadow-card transition-shadow hover:shadow-card-hover"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-primary-dark truncate">{(locale === 'es' && event.titleEs) || event.title}</p>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
<span className="inline-flex items-center gap-1"><CalendarIcon className="h-3.5 w-3.5" />{fmt(event.startDatetime)}</span>
|
||||
<span className="inline-flex items-center gap-1 min-w-0"><MapPinIcon className="h-3.5 w-3.5 flex-shrink-0" /><span className="truncate">{event.location}</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 whitespace-nowrap">
|
||||
{t(`admin.rolePresets.${rolePreset}`)}
|
||||
</span>
|
||||
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-gray-400" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { PrivacyProvider } from '@/context/PrivacyContext';
|
||||
import { EventDetailView } from '@/app/admin/events/[id]/_components/EventDetailView';
|
||||
|
||||
/**
|
||||
* The single event page for team members (staff / collaborators / co-managers
|
||||
* added in the event's Team tab). Same view as the admin page; tabs and data
|
||||
* follow the member's permissions on this event only.
|
||||
*/
|
||||
export default function MemberEventPage() {
|
||||
const params = useParams();
|
||||
return (
|
||||
<PrivacyProvider>
|
||||
<div className="section-padding min-h-[70vh]">
|
||||
<div className="container-page">
|
||||
<EventDetailView eventId={params.id as string} backHref="/dashboard?tab=events" />
|
||||
</div>
|
||||
</div>
|
||||
</PrivacyProvider>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
dashboardApi,
|
||||
financeApi,
|
||||
NextEventInfo,
|
||||
UserTicket,
|
||||
UserPayment,
|
||||
type MyEvent,
|
||||
} from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { CardListSkeleton } from '@/components/ui/Skeleton';
|
||||
@@ -17,20 +19,28 @@ import OverviewTab from './components/OverviewTab';
|
||||
import TicketsTab from './components/TicketsTab';
|
||||
import PaymentsTab from './components/PaymentsTab';
|
||||
import AccountTab from './components/AccountTab';
|
||||
import MyEventsTab from './components/MyEventsTab';
|
||||
|
||||
type Tab = 'overview' | 'tickets' | 'payments' | 'account';
|
||||
type Tab = 'overview' | 'tickets' | 'payments' | 'events' | 'account';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { locale } = useLanguage();
|
||||
const { locale, t } = useLanguage();
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
||||
const [tickets, setTickets] = useState<UserTicket[]>([]);
|
||||
const [payments, setPayments] = useState<UserPayment[]>([]);
|
||||
const [myEvents, setMyEvents] = useState<MyEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ?tab=events (the back link from a team event page) opens My Events.
|
||||
useEffect(() => {
|
||||
const tab = new URLSearchParams(window.location.search).get('tab');
|
||||
if (tab === 'events') setActiveTab('events');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/login');
|
||||
@@ -47,14 +57,17 @@ export default function DashboardPage() {
|
||||
const loadDashboardData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
|
||||
const [nextEventRes, ticketsRes, paymentsRes, myEventsRes] = await Promise.all([
|
||||
dashboardApi.getNextEvent(),
|
||||
dashboardApi.getTickets(),
|
||||
dashboardApi.getPayments(),
|
||||
// Team memberships are optional extra; never fail the dashboard over them.
|
||||
financeApi.myEvents().catch(() => ({ events: [] as MyEvent[] })),
|
||||
]);
|
||||
setNextEvent(nextEventRes.nextEvent);
|
||||
setTickets(ticketsRes.tickets);
|
||||
setPayments(paymentsRes.payments);
|
||||
setMyEvents(myEventsRes.events);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
|
||||
@@ -67,6 +80,8 @@ export default function DashboardPage() {
|
||||
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
|
||||
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
|
||||
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
|
||||
// Only for people on at least one event team
|
||||
...(myEvents.length > 0 ? [{ id: 'events' as Tab, label: { en: t('dashboard.myEvents.tab'), es: t('dashboard.myEvents.tab') } }] : []),
|
||||
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
|
||||
];
|
||||
|
||||
@@ -125,6 +140,7 @@ export default function DashboardPage() {
|
||||
{activeTab === 'payments' && (
|
||||
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
|
||||
)}
|
||||
{activeTab === 'events' && <MyEventsTab events={myEvents} />}
|
||||
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,11 +18,14 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
|
||||
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
|
||||
|
||||
const now = new Date();
|
||||
// An event that has started but still sells at the door stays under Upcoming.
|
||||
const isUpcoming = (e: Event) =>
|
||||
new Date(e.startDatetime) >= now || e.salesState === 'door';
|
||||
const upcomingEvents = initialEvents.filter(e =>
|
||||
e.status === 'published' && new Date(e.startDatetime) >= now
|
||||
e.status === 'published' && isUpcoming(e)
|
||||
);
|
||||
const pastEvents = initialEvents.filter(e =>
|
||||
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
|
||||
e.status === 'completed' || (e.status === 'published' && !isUpcoming(e))
|
||||
);
|
||||
|
||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||
@@ -34,9 +37,16 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
|
||||
if (event.status === 'cancelled') {
|
||||
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
|
||||
}
|
||||
if (event.availableSeats === 0) {
|
||||
if (event.salesState === 'sold_out' || event.availableSeats === 0) {
|
||||
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
|
||||
}
|
||||
if (event.salesState === 'door') {
|
||||
return (
|
||||
<span className="badge bg-primary-yellow/20 text-primary-dark whitespace-nowrap">
|
||||
{t('events.door.badge')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -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