121 lines
7.2 KiB
TypeScript
121 lines
7.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { onAuthStateChanged } from 'firebase/auth';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { firebaseAuth } from '../../lib/firebase-client';
|
|
import {
|
|
fetchClubRides,
|
|
fetchFounderClubContext,
|
|
sanitizePortalError,
|
|
type ClubRidesResult,
|
|
type FounderClubContext,
|
|
} from '../../lib/club-data';
|
|
import { FounderPortalShell } from './founder-portal-shell';
|
|
import { MetricCard } from './metric-card';
|
|
import { OverviewSkeleton } from './skeleton-state';
|
|
import { SafeErrorState } from './safe-error-state';
|
|
|
|
export function FounderStatsPage({ clubId }: { clubId: string }) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo';
|
|
const [context, setContext] = useState<FounderClubContext | null>(null);
|
|
const [rides, setRides] = useState<ClubRidesResult | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [access, setAccess] = useState<'sign-in' | 'restricted' | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [retry, setRetry] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let generation = 0;
|
|
let disposed = false;
|
|
const unsubscribe = onAuthStateChanged(firebaseAuth, async (user) => {
|
|
const current = ++generation;
|
|
setContext(null); setRides(null); setLoading(true); setAccess(null); setError(null);
|
|
if (!demo && (!user || user.isAnonymous)) {
|
|
setAccess('sign-in'); setLoading(false); return;
|
|
}
|
|
try {
|
|
const resolved = await fetchFounderClubContext(clubId, user?.uid, { allowDemoPreview: demo });
|
|
if (disposed || current !== generation) return;
|
|
if (!resolved.permissions.canManageSettings && !resolved.isDemoFixture) {
|
|
setAccess('restricted'); return;
|
|
}
|
|
const clubRides = await fetchClubRides(resolved.club.id, user?.uid, { allowDemoPreview: demo });
|
|
if (disposed || current !== generation) return;
|
|
setContext(resolved);
|
|
setRides(clubRides);
|
|
} catch (err) {
|
|
if (!disposed && current === generation) setError(sanitizePortalError(err, 'Club stats could not be loaded.'));
|
|
} finally {
|
|
if (!disposed && current === generation) setLoading(false);
|
|
}
|
|
});
|
|
return () => { disposed = true; generation++; unsubscribe(); };
|
|
}, [clubId, demo, retry]);
|
|
|
|
if (loading) return <div className="min-h-screen bg-brand-bg p-6"><OverviewSkeleton /></div>;
|
|
if (access) return <SafeErrorState title={access === 'sign-in' ? 'Authentication Required' : 'Management Access Restricted'}
|
|
message={access === 'sign-in' ? 'Sign in to view club stats.' : 'Only club Owners and Admins can view club stats.'}
|
|
actionLabel={access === 'sign-in' ? 'Sign In' : 'Return to Clubs'}
|
|
onAction={() => router.push(access === 'sign-in' ? '/login' : '/clubs')} />;
|
|
if (error || !context || !rides) return <SafeErrorState title="Club stats unavailable"
|
|
message={error || undefined} onRetry={() => setRetry((value) => value + 1)}
|
|
actionLabel="Return to Clubs" onAction={() => router.push('/clubs')} />;
|
|
|
|
const completed = rides.pastRides.filter((ride) => ride.status === 'completed');
|
|
const canceled = rides.pastRides.filter((ride) => ride.status === 'canceled');
|
|
const confirmedSeats = rides.upcomingRides.reduce((sum, ride) => sum + ride.confirmedCount, 0);
|
|
const ridePath = `/clubs/${context.club.slug || context.club.id}/manage/rides${demo ? '?preview=demo' : ''}`;
|
|
|
|
return <FounderPortalShell club={context.club} permissions={context.permissions} activeNav="stats">
|
|
<div className="space-y-7">
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<p className="font-mono text-xs uppercase tracking-wider text-brand-cyan">Club overview</p>
|
|
<h1 className="font-headline text-3xl font-bold text-on-surface">Stats</h1>
|
|
<p className="mt-2 text-sm text-on-surface-variant">Current roster and the rides available in club management.</p>
|
|
</div>
|
|
<Link href={ridePath} className="rounded-xl border border-outline-variant px-4 py-2 text-sm font-semibold text-brand-cyan hover:bg-surface-container">Manage rides</Link>
|
|
</div>
|
|
|
|
<section aria-label="Club statistics" className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
<MetricCard label="Active members" value={context.club.memberCount} subtextLeft="Club roster count" />
|
|
<MetricCard label="Scheduled or active" value={rides.upcomingRides.length} subtextLeft="Loaded club rides" disclaimer="Shows up to 30 rides; this is not an all-time total." />
|
|
<MetricCard label="Completed" value={completed.length} subtextLeft="Recently loaded rides" disclaimer="Shows up to 30 recent completed or canceled rides." />
|
|
<MetricCard label="Confirmed ride seats" value={confirmedSeats} subtextLeft="Across loaded scheduled and active rides" disclaimer="A rider on two rides counts twice. Shows up to 30 rides." />
|
|
</section>
|
|
|
|
<div className="grid gap-5 lg:grid-cols-2">
|
|
<section className="rounded-2xl border border-brand-border/60 bg-surface-container-low p-5" aria-labelledby="ride-status-title">
|
|
<h2 id="ride-status-title" className="font-headline text-lg font-semibold text-on-surface">Ride status</h2>
|
|
<p className="mt-1 text-xs text-on-surface-variant">From the club rides currently loaded in management.</p>
|
|
<dl className="mt-5 space-y-3 text-sm">
|
|
{[
|
|
['Scheduled', rides.upcomingRides.filter((ride) => ride.status === 'scheduled').length],
|
|
['Assembling', rides.upcomingRides.filter((ride) => ride.status === 'assembling').length],
|
|
['Underway', rides.upcomingRides.filter((ride) => ride.status === 'underway').length],
|
|
['Completed', completed.length],
|
|
['Canceled', canceled.length],
|
|
['Unclassified', [...rides.upcomingRides, ...rides.pastRides].filter((ride) => !ride.status).length],
|
|
].map(([label, count]) => <div key={label} className="flex justify-between border-b border-brand-border/40 pb-2">
|
|
<dt className="text-on-surface-variant">{label}</dt><dd className="font-mono font-semibold text-on-surface">{count}</dd>
|
|
</div>)}
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="rounded-2xl border border-brand-border/60 bg-surface-container-low p-5" aria-labelledby="recent-rides-title">
|
|
<h2 id="recent-rides-title" className="font-headline text-lg font-semibold text-on-surface">Recent completed rides</h2>
|
|
{completed.length === 0 ? <p className="mt-5 text-sm text-on-surface-variant">No completed rides in the recent club ride list.</p>
|
|
: <ul className="mt-4 space-y-3">{completed.slice(0, 5).map((ride) => <li key={ride.id} className="flex justify-between gap-4 border-b border-brand-border/40 pb-3 text-sm">
|
|
<span className="min-w-0 truncate text-on-surface">{ride.title}</span>
|
|
<span className="shrink-0 font-mono text-on-surface-variant">{ride.confirmedCount} seats</span>
|
|
</li>)}</ul>}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</FounderPortalShell>;
|
|
}
|