249 lines
9.7 KiB
TypeScript
249 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, use } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import {
|
|
fetchFounderClubContext,
|
|
FounderClubContext,
|
|
fetchClubRides,
|
|
fetchClubApplications,
|
|
sanitizePortalError,
|
|
} from '../../../../../lib/club-data';
|
|
import { ClubUpcomingRide } from '../../../../../components/founder-portal/types';
|
|
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
|
|
import { FounderRides } from '../../../../../components/founder-portal/founder-rides';
|
|
import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state';
|
|
import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state';
|
|
import { firebaseAuth } from '../../../../../lib/firebase-client';
|
|
|
|
interface PageProps {
|
|
params: Promise<{ clubId: string }>;
|
|
}
|
|
|
|
export default function ClubFounderRidesPage({ params }: PageProps) {
|
|
const { clubId } = use(params);
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
const [context, setContext] = useState<FounderClubContext | null>(null);
|
|
const [upcomingRides, setUpcomingRides] = useState<ClubUpcomingRide[]>([]);
|
|
const [pastRides, setPastRides] = useState<ClubUpcomingRide[]>([]);
|
|
const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [unauthenticated, setUnauthenticated] = useState(false);
|
|
const [unauthorized, setUnauthorized] = useState(false);
|
|
|
|
const isDemoPreview = searchParams?.get('preview') === 'demo';
|
|
|
|
async function loadData() {
|
|
setLoading(true);
|
|
setError(null);
|
|
setUnauthenticated(false);
|
|
setUnauthorized(false);
|
|
|
|
try {
|
|
await firebaseAuth.authStateReady();
|
|
const currentUser = firebaseAuth.currentUser;
|
|
|
|
// Unauthenticated Guard: Direct access without demo preview requires authentication
|
|
if (!isDemoPreview && !currentUser) {
|
|
setUnauthenticated(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, {
|
|
allowDemoPreview: isDemoPreview,
|
|
});
|
|
|
|
// Authorization Guard: Owner and Admin can access Rides management; Members cannot
|
|
if (!isDemoPreview && (!ctx.permissions.canManageSettings && !ctx.permissions.canPlanRides)) {
|
|
setUnauthorized(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setContext(ctx);
|
|
|
|
// Fetch pending applications count for sidebar badge
|
|
try {
|
|
const apps = await fetchClubApplications(ctx.club.id, currentUser?.uid, {
|
|
allowDemoPreview: isDemoPreview,
|
|
});
|
|
setPendingApplicationsCount(apps.length);
|
|
} catch {
|
|
setPendingApplicationsCount(0);
|
|
}
|
|
|
|
// In demo preview mode: load fixture rides for UI/design validation
|
|
if (isDemoPreview) {
|
|
const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, {
|
|
allowDemoPreview: true,
|
|
});
|
|
setUpcomingRides(ridesResult.upcomingRides);
|
|
setPastRides(ridesResult.pastRides);
|
|
}
|
|
// In production mode, do not attempt a broad client-side clubId query.
|
|
// GroupRide.clubId is now persisted, but Firestore intentionally does not authorize
|
|
// Club-wide discovery until mutual-block privacy can be preserved.
|
|
} catch (err) {
|
|
setError(sanitizePortalError(err, "Club rides couldn't be loaded."));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadData();
|
|
}, [clubId]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
|
<div className="max-w-5xl mx-auto">
|
|
<OverviewSkeleton />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Unauthenticated safe error state
|
|
if (unauthenticated) {
|
|
return (
|
|
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
|
<SafeErrorState
|
|
title="Authentication Required"
|
|
message="Sign in with your rider account to access club rides."
|
|
actionLabel="Sign In"
|
|
onAction={() => router.push('/login')}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Unauthorized (Member or non-admin) safe error state
|
|
if (unauthorized) {
|
|
return (
|
|
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
|
<SafeErrorState
|
|
title="Management Access Restricted"
|
|
message="You must be an Owner or Admin of this Club to manage Club Rides."
|
|
actionLabel="Return to Clubs"
|
|
onAction={() => router.push('/clubs')}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error or missing context
|
|
if (error || !context) {
|
|
return (
|
|
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
|
<SafeErrorState
|
|
title="Rides Unavailable"
|
|
message={error ?? "Club rides couldn't be loaded."}
|
|
actionLabel="Return to Clubs"
|
|
onAction={() => router.push('/clubs')}
|
|
onRetry={loadData}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Normal production operation: truthful discovery-pending state.
|
|
// GroupRide.clubId exists, but no privacy-preserving Club-wide read path is available.
|
|
// Do not describe this state as "No rides".
|
|
if (!isDemoPreview) {
|
|
return (
|
|
<FounderPortalShell
|
|
club={context.club}
|
|
permissions={context.permissions}
|
|
activeNav="rides"
|
|
pendingApplicationsCount={pendingApplicationsCount}
|
|
upcomingRidesCount={0}
|
|
>
|
|
<div className="space-y-6" aria-label="Club Rides Management">
|
|
{/* Header Section */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-2.5">
|
|
<h1 className="font-headline text-xl sm:text-2xl font-bold text-white tracking-tight">
|
|
Club Rides
|
|
</h1>
|
|
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-400/10 text-amber-400 border border-amber-400/30 font-semibold">
|
|
DISCOVERY PENDING
|
|
</span>
|
|
</div>
|
|
<p className="font-body text-xs text-slate-400 mt-1">
|
|
Schedule, coordinate, and review group rides and road captain assignments.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-400 font-body text-xs cursor-not-allowed select-none"
|
|
title="Founder Portal ride planning is not wired yet"
|
|
aria-disabled="true"
|
|
>
|
|
<span className="material-symbols-outlined text-[17px] text-slate-500">add_road</span>
|
|
<span className="text-slate-400">Plan Club Ride</span>
|
|
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-400 border border-brand-border/60">
|
|
SOON
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pending Backend Integration Notice */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-8 text-center space-y-4 max-w-2xl mx-auto my-8">
|
|
<div className="w-12 h-12 rounded-2xl bg-amber-400/10 border border-amber-400/30 flex items-center justify-center text-amber-400 mx-auto">
|
|
<span className="material-symbols-outlined text-[28px]">route</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h2 className="font-headline text-lg sm:text-xl font-bold text-white">
|
|
Club Rides Discovery Pending
|
|
</h2>
|
|
<p className="font-body text-xs sm:text-sm text-slate-400 max-w-lg mx-auto leading-relaxed">
|
|
Club-affiliated Group Rides can now be created in the mobile application. Club-wide Founder Portal discovery remains disabled until a read path can preserve mutual-block privacy.
|
|
</p>
|
|
</div>
|
|
<div className="pt-2 border-t border-brand-border/40 text-left text-xs text-slate-400 space-y-2 bg-surface-container-low/60 rounded-xl p-4">
|
|
<div className="flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-[16px] text-brand-cyan shrink-0 mt-0.5">info</span>
|
|
<span>
|
|
<strong>Architecture Note:</strong> Group rides now link to clubs through <code className="font-mono text-[11px] text-brand-cyan">GroupRide.clubId == club.id</code>. Club-wide discovery is a separate deferred capability.
|
|
</span>
|
|
</div>
|
|
<div className="flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-[16px] text-brand-cyan shrink-0 mt-0.5">shield</span>
|
|
<span>
|
|
<strong>Privacy Invariant:</strong> Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</FounderPortalShell>
|
|
);
|
|
}
|
|
|
|
// Explicit Development Demo Preview: Renders full interactive UI with fixtures
|
|
return (
|
|
<FounderPortalShell
|
|
club={context.club}
|
|
permissions={context.permissions}
|
|
activeNav="rides"
|
|
pendingApplicationsCount={pendingApplicationsCount}
|
|
upcomingRidesCount={upcomingRides.length}
|
|
>
|
|
<FounderRides
|
|
club={context.club}
|
|
permissions={context.permissions}
|
|
upcomingRides={upcomingRides}
|
|
pastRides={pastRides}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
</FounderPortalShell>
|
|
);
|
|
}
|