feat(web): add clubs rides management (#9)
Add the disabled Founder Portal Club Rides scaffold aligned with the live GroupRide.clubId domain. Keep production discovery and actions disabled until a privacy-preserving read path and real management destinations are implemented.
This commit is contained in:
parent
68d4539f90
commit
59900f46f2
|
|
@ -0,0 +1,248 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { ClubUpcomingRide, UserPortalPermissions } from './types';
|
||||
import { EmptyState } from './empty-state';
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ export function ClubRideCard({
|
|||
{/* Attributes Row */}
|
||||
<div className="grid grid-cols-3 gap-2 py-2.5 bg-surface-container-low/80 rounded-xl px-3.5 my-3 border border-brand-border/60">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-slate-400">Distance</div>
|
||||
<div className="font-mono text-[10px] text-slate-400">Straight-line distance</div>
|
||||
<div className="font-headline text-sm font-semibold text-slate-100 mt-0.5">
|
||||
{ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'}
|
||||
</div>
|
||||
|
|
@ -131,20 +131,24 @@ export function ClubRideCard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Actions: Disabled until backend club-ride management ships */}
|
||||
<div className="mt-4 pt-3 border-t border-brand-border/60 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canPlanRides ? (
|
||||
<button
|
||||
onClick={() => onManageRide?.(ride.id)}
|
||||
className="h-9 px-3.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-sm transition-all cursor-pointer"
|
||||
type="button"
|
||||
disabled
|
||||
title="Ride management is planned for a future release"
|
||||
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs font-medium cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
Manage Ride
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onViewRoster?.(ride.id)}
|
||||
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors cursor-pointer"
|
||||
type="button"
|
||||
disabled
|
||||
title="Ride details are planned for a future release"
|
||||
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs font-medium cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
View Ride
|
||||
</button>
|
||||
|
|
@ -161,10 +165,12 @@ export function ClubRideCard({
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onViewRoute?.(ride.routeId ?? undefined)}
|
||||
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-300 hover:text-white font-body text-xs flex items-center gap-1.5 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
disabled
|
||||
title="Route navigation is planned for a future release"
|
||||
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs flex items-center gap-1.5 cursor-not-allowed select-none"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px] text-brand-cyan">map</span>
|
||||
<span className="material-symbols-outlined text-[15px] text-slate-600">map</span>
|
||||
<span>View Route</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -182,6 +188,9 @@ export function UpcomingRidesSection({
|
|||
onViewRoute,
|
||||
}: UpcomingRidesSectionProps) {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const isDemo = searchParams?.get('preview') === 'demo';
|
||||
const queryParam = isDemo ? '?preview=demo' : '';
|
||||
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
|
||||
|
||||
return (
|
||||
|
|
@ -197,13 +206,13 @@ export function UpcomingRidesSection({
|
|||
</div>
|
||||
|
||||
{rides.length > 0 && (
|
||||
<span
|
||||
title="Full ride schedule is planned for a future release"
|
||||
className="font-mono text-xs text-slate-500 flex items-center gap-1 cursor-not-allowed"
|
||||
<Link
|
||||
href={`/clubs/${targetClubId}/manage/rides${queryParam}`}
|
||||
className="font-mono text-xs text-brand-cyan hover:text-white flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<span>Full Ride Schedule</span>
|
||||
<span className="material-symbols-outlined text-[13px] text-slate-600">schedule</span>
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[13px]">arrow_forward</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -212,8 +221,7 @@ export function UpcomingRidesSection({
|
|||
icon="route"
|
||||
title="No Club Rides are currently scheduled."
|
||||
description="Plan a group route, designate pacelines, and invite club riders."
|
||||
actionLabel={permissions.canPlanRides ? 'Plan Club Ride' : undefined}
|
||||
onAction={permissions.canPlanRides ? onPlanRide : undefined}
|
||||
actionLabel={undefined}
|
||||
className="py-10"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Club, ClubUpcomingRide, UserPortalPermissions } from './types';
|
||||
import { EmptyState } from './empty-state';
|
||||
import { ClubRideCard } from './club-ride-card';
|
||||
|
||||
interface FounderRidesProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
upcomingRides: ClubUpcomingRide[];
|
||||
pastRides: ClubUpcomingRide[];
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onPlanRide?: () => void;
|
||||
onManageRide?: (rideId: string) => void;
|
||||
onViewRoster?: (rideId: string) => void;
|
||||
onViewRoute?: (routeId?: string) => void;
|
||||
}
|
||||
|
||||
type TabType = 'upcoming' | 'completed' | 'all';
|
||||
|
||||
export function FounderRides({
|
||||
club,
|
||||
permissions,
|
||||
upcomingRides,
|
||||
pastRides,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onPlanRide,
|
||||
onManageRide,
|
||||
onViewRoster,
|
||||
onViewRoute,
|
||||
}: FounderRidesProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('upcoming');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [paceFilter, setPaceFilter] = useState('all');
|
||||
|
||||
const allRides = useMemo(() => {
|
||||
return [...upcomingRides, ...pastRides];
|
||||
}, [upcomingRides, pastRides]);
|
||||
|
||||
// Available pace options from actual data
|
||||
const paceOptions = useMemo(() => {
|
||||
const paces = new Set<string>();
|
||||
for (const r of allRides) {
|
||||
if (r.paceClassification) paces.add(r.paceClassification);
|
||||
}
|
||||
return Array.from(paces);
|
||||
}, [allRides]);
|
||||
|
||||
// Tab-filtered rides
|
||||
const tabRides = useMemo(() => {
|
||||
if (activeTab === 'upcoming') return upcomingRides;
|
||||
if (activeTab === 'completed') return pastRides;
|
||||
return allRides;
|
||||
}, [activeTab, upcomingRides, pastRides, allRides]);
|
||||
|
||||
// Search & pace filtered rides
|
||||
const filteredRides = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
return tabRides.filter((ride) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
ride.title.toLowerCase().includes(q) ||
|
||||
ride.rideIdentifier.toLowerCase().includes(q) ||
|
||||
ride.roadCaptainName.toLowerCase().includes(q) ||
|
||||
(ride.roadCaptainHandle && ride.roadCaptainHandle.toLowerCase().includes(q)) ||
|
||||
(ride.description && ride.description.toLowerCase().includes(q));
|
||||
|
||||
const matchesPace = paceFilter === 'all' || ride.paceClassification === paceFilter;
|
||||
|
||||
return matchesSearch && matchesPace;
|
||||
});
|
||||
}, [tabRides, searchQuery, paceFilter]);
|
||||
|
||||
// Metrics summary
|
||||
const upcomingCount = upcomingRides.length;
|
||||
const confirmedRidersTotal = upcomingRides.reduce((sum, r) => sum + r.confirmedCount, 0);
|
||||
const completedCount = pastRides.length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse" aria-label="Loading club rides">
|
||||
<div className="h-8 bg-surface-container-high rounded-xl w-64" />
|
||||
<div className="h-20 bg-surface-container-high rounded-2xl" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-28 bg-surface-container-high rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 bg-surface-container-high rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-error-container/20 border border-error/30 p-6 text-center space-y-3">
|
||||
<span className="material-symbols-outlined text-error text-[36px]">error</span>
|
||||
<h3 className="font-headline text-base font-bold text-white">Unable to Load Club Rides</h3>
|
||||
<p className="font-body text-xs text-slate-400 max-w-md mx-auto">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 py-0.5 rounded-full bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/30 font-semibold">
|
||||
{upcomingCount} Scheduled
|
||||
</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>
|
||||
|
||||
{/* Architecture & Privacy Notice Banner */}
|
||||
<div className="rounded-2xl bg-surface-container-low border border-brand-border/60 p-4 space-y-2">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="material-symbols-outlined text-[18px] text-brand-cyan shrink-0 mt-0.5">
|
||||
shield
|
||||
</span>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-body text-slate-300">
|
||||
Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
|
||||
</p>
|
||||
<p className="font-mono text-[11px] text-slate-400">
|
||||
Discovery Pending: GroupRide.clubId is live, but broad Founder Portal discovery remains disabled until mutual-block privacy can be preserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Summary Strip (4 Cards) */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{/* Card 1: Upcoming Rides */}
|
||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="font-mono text-xs tracking-wider">UPCOMING RIDES</span>
|
||||
<span className="material-symbols-outlined text-[18px] text-brand-cyan">route</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
|
||||
{upcomingCount}
|
||||
</div>
|
||||
<div className="font-body text-[11px] text-slate-400 mt-0.5">
|
||||
Active & scheduled
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Confirmed Riders */}
|
||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="font-mono text-xs tracking-wider">CONFIRMED RIDERS</span>
|
||||
<span className="material-symbols-outlined text-[18px] text-brand-cyan">group</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
|
||||
{confirmedRidersTotal}
|
||||
</div>
|
||||
<div className="font-body text-[11px] text-slate-400 mt-0.5">
|
||||
Across upcoming rides
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Completed Rides */}
|
||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="font-mono text-xs tracking-wider">COMPLETED RIDES</span>
|
||||
<span className="material-symbols-outlined text-[18px] text-brand-cyan">task_alt</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
|
||||
{completedCount}
|
||||
</div>
|
||||
<div className="font-body text-[11px] text-slate-400 mt-0.5">
|
||||
Recorded club events
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Club Miles */}
|
||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="font-mono text-xs tracking-wider">CLUB MILES</span>
|
||||
<span className="material-symbols-outlined text-[18px] text-slate-500">lock</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="font-headline text-2xl sm:text-3xl font-bold text-slate-400">
|
||||
--
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-slate-500 mt-0.5">
|
||||
Completed Club Rides only
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-slate-500 font-body border-t border-brand-border/40 pt-1.5">
|
||||
Calculated from eligible completed Club Rides. Private rider mileage is not automatically included.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs & Search Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-1 border-b border-brand-border/60">
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex items-center gap-1 bg-surface-container-low p-1 rounded-xl border border-brand-border/60">
|
||||
<button
|
||||
onClick={() => setActiveTab('upcoming')}
|
||||
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
|
||||
activeTab === 'upcoming'
|
||||
? 'bg-primary-container text-on-primary-container'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Upcoming ({upcomingCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('completed')}
|
||||
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
|
||||
activeTab === 'completed'
|
||||
? 'bg-primary-container text-on-primary-container'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Completed ({completedCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
|
||||
activeTab === 'all'
|
||||
? 'bg-primary-container text-on-primary-container'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
All ({allRides.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search & Pace Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search Input */}
|
||||
<div className="relative min-w-[180px] sm:min-w-[220px]">
|
||||
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500 text-[16px]">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search rides or captains..."
|
||||
className="w-full h-8 pl-8 pr-3 rounded-lg bg-surface-container-low border border-brand-border/60 text-white placeholder:text-slate-500 font-body text-xs focus:outline-none focus:border-brand-cyan transition-colors"
|
||||
aria-label="Search rides or road captains"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pace Filter Dropdown */}
|
||||
{paceOptions.length > 0 && (
|
||||
<select
|
||||
value={paceFilter}
|
||||
onChange={(e) => setPaceFilter(e.target.value)}
|
||||
className="h-8 px-2.5 rounded-lg bg-surface-container-low border border-brand-border/60 text-slate-300 font-body text-xs focus:outline-none focus:border-brand-cyan cursor-pointer"
|
||||
aria-label="Filter by pace classification"
|
||||
>
|
||||
<option value="all">All Paces</option>
|
||||
{paceOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ride List */}
|
||||
{filteredRides.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="route"
|
||||
title={
|
||||
searchQuery || paceFilter !== 'all'
|
||||
? 'No rides match the selected filters.'
|
||||
: activeTab === 'completed'
|
||||
? 'No completed club rides recorded.'
|
||||
: 'No upcoming club rides scheduled.'
|
||||
}
|
||||
description={
|
||||
searchQuery || paceFilter !== 'all'
|
||||
? 'Try adjusting your search query or pace filter.'
|
||||
: activeTab === 'completed'
|
||||
? 'Completed group rides with telemetry bundles will appear here.'
|
||||
: 'Scheduled club rides will appear here once club ride coordination is active.'
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{filteredRides.map((ride) => (
|
||||
<ClubRideCard
|
||||
key={ride.id}
|
||||
ride={ride}
|
||||
permissions={permissions}
|
||||
clubId={club.slug || club.id}
|
||||
onManageRide={onManageRide}
|
||||
onViewRoster={onViewRoster}
|
||||
onViewRoute={onViewRoute}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,9 +58,9 @@ export function FounderSidebar({
|
|||
id: 'rides',
|
||||
label: 'Rides',
|
||||
icon: 'route',
|
||||
badge: 'SOON',
|
||||
badgeType: 'disabled',
|
||||
disabled: true,
|
||||
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount}` : undefined,
|
||||
badgeType: 'secondary',
|
||||
disabled: false,
|
||||
},
|
||||
{ id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
{ id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
|
|
@ -78,6 +78,7 @@ export function FounderSidebar({
|
|||
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
||||
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
|
||||
if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`;
|
||||
if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`;
|
||||
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -100,7 +101,8 @@ export function FounderSidebar({
|
|||
const isProfile = item.id === 'profile';
|
||||
const isSettings = item.id === 'settings';
|
||||
const isApplications = item.id === 'applications';
|
||||
const isSubRoute = isProfile || isSettings || isApplications || item.id === 'ranks_roles';
|
||||
const isRides = item.id === 'rides';
|
||||
const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles';
|
||||
|
||||
if (item.disabled) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -92,6 +92,12 @@ export function FounderTopBar({
|
|||
<span className="text-brand-cyan font-semibold truncate">Ranks & Roles</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'rides' && (
|
||||
<>
|
||||
<span className="text-outline-variant shrink-0">/</span>
|
||||
<span data-breadcrumb="RIDES" className="text-brand-cyan font-semibold truncate">Rides</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'settings' && (
|
||||
<>
|
||||
<span className="text-outline-variant shrink-0">/</span>
|
||||
|
|
|
|||
|
|
@ -63,21 +63,22 @@ export interface ClubMember {
|
|||
}
|
||||
|
||||
/**
|
||||
* Club-affiliated upcoming ride model.
|
||||
* Club-affiliated ride model (Founder Portal presentation view).
|
||||
*
|
||||
* CANONICAL AFFILIATION CONTRACT:
|
||||
* - Authoritative link: GroupRide.clubId == club.id
|
||||
* - Affiliation is NEVER inferred from accepted rosters, participant lists,
|
||||
* organizer membership in a Club, accessUids, or live presence.
|
||||
* ARCHITECTURAL STATUS:
|
||||
* - Production GroupRide documents support optional immutable clubId affiliation.
|
||||
* - The canonical link is GroupRide.clubId == club.id.
|
||||
* - Broad client-side Club discovery remains deferred for mutual-block privacy.
|
||||
* - Hierarchy: Club (/clubs/{clubId}) -> GroupRide (/group_rides/{groupRideId}) -> Route (/routes/{routeId})
|
||||
* - PRIVACY INVARIANT: ClubMember != GroupRideParticipant.
|
||||
* Club membership alone never grants access to rider location or ride presence.
|
||||
* - LIFECYCLE: Completion is indicated strictly by status == 'completed' (no completion timestamp).
|
||||
*
|
||||
* ROUTE METRICS CONTRACT:
|
||||
* - Derived from MotorSignalRoute (/routes/{routeId}).
|
||||
* - straightLineDistanceMeters: Geodesic Haversine distance between ordered stops (persisted).
|
||||
* - estimatedDistanceMeters & estimatedDurationSeconds: Optional Valhalla road estimates.
|
||||
* - When unpopulated or optional, UI displays '--' without fabricating geometry or duration.
|
||||
* - straightLineDistanceMeters: Geodesic Haversine distance between ordered stops (persisted)
|
||||
* - estimatedDistanceMeters & estimatedDurationSeconds: Optional Valhalla road estimates
|
||||
* - routeDistanceMeters is a STRAIGHT-LINE snapshot, NOT a guaranteed road distance.
|
||||
* - UI displays '--' when distance/duration is unpopulated without fabricating values.
|
||||
*/
|
||||
export interface ClubUpcomingRide {
|
||||
id: string;
|
||||
|
|
@ -85,7 +86,7 @@ export interface ClubUpcomingRide {
|
|||
title: string;
|
||||
scheduledStartAt: string; // formatted or ISO
|
||||
departureAt?: string;
|
||||
distanceMiles?: number | null;
|
||||
distanceMiles?: number | null; // Straight-line preview in miles
|
||||
durationString?: string | null;
|
||||
paceClassification: string; // e.g. "Brisk Paceline", "Scenic Pace"
|
||||
roadCaptainName: string;
|
||||
|
|
@ -98,6 +99,9 @@ export interface ClubUpcomingRide {
|
|||
capacity?: number | null;
|
||||
briefingTime?: string | null;
|
||||
routeId?: string | null;
|
||||
status?: string | null;
|
||||
organizerUid?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ClubMetrics {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
FIXTURE_FOUNDER_CLUB,
|
||||
FIXTURE_METRICS,
|
||||
FIXTURE_UPCOMING_RIDES,
|
||||
FIXTURE_PAST_RIDES,
|
||||
FIXTURE_PENDING_APPLICATIONS,
|
||||
} from './club-fixtures';
|
||||
|
||||
|
|
@ -1172,4 +1173,42 @@ export async function declineClubApplication(
|
|||
});
|
||||
}
|
||||
|
||||
export interface ClubRidesResult {
|
||||
upcomingRides: ClubUpcomingRide[];
|
||||
pastRides: ClubUpcomingRide[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the Founder Portal Club Rides scaffold.
|
||||
*
|
||||
* CURRENT PRODUCTION CONTRACT:
|
||||
* - GroupRide.clubId is persisted by the Flutter application.
|
||||
* - Firestore intentionally does not authorize a broad client-side clubId list query,
|
||||
* because discovery must preserve mutual-block privacy.
|
||||
* - Production callers must not infer Club membership as GroupRide participation or
|
||||
* live-location access.
|
||||
* - The explicit development preview remains fixture-backed.
|
||||
*/
|
||||
export async function fetchClubRides(
|
||||
clubId: string,
|
||||
_callerUid?: string,
|
||||
options?: { allowDemoPreview?: boolean }
|
||||
): Promise<ClubRidesResult> {
|
||||
const normalized = (clubId || '').trim();
|
||||
|
||||
if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') {
|
||||
return {
|
||||
upcomingRides: FIXTURE_UPCOMING_RIDES,
|
||||
pastRides: FIXTURE_PAST_RIDES,
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Club ride discovery is not available until a privacy-preserving production read path is implemented.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,8 +121,50 @@ export const FIXTURE_PENDING_APPLICATIONS: ClubMember[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export const FIXTURE_PAST_RIDES: ClubUpcomingRide[] = [
|
||||
{
|
||||
id: 'ride-ms-atl-880',
|
||||
rideIdentifier: 'MS-ATL-880',
|
||||
title: 'Russell-Brasstown Scenic Byway Loop',
|
||||
scheduledStartAt: 'Oct 11 · 8:00 AM',
|
||||
distanceMiles: 118,
|
||||
durationString: '3h 10m',
|
||||
paceClassification: 'Brisk Paceline',
|
||||
roadCaptainName: 'Dave K.',
|
||||
roadCaptainMotorcycle: 'Yamaha MT-09 SP',
|
||||
sweepName: 'Marcus Vance',
|
||||
sweepStatus: 'assigned',
|
||||
confirmedCount: 22,
|
||||
pendingCount: 0,
|
||||
capacity: 25,
|
||||
routeId: 'rt-russell-brasstown-01',
|
||||
status: 'completed',
|
||||
description: 'Crisp autumn run across the highest gap in Georgia with sweep radar coordination.',
|
||||
},
|
||||
{
|
||||
id: 'ride-ms-atl-879',
|
||||
rideIdentifier: 'MS-ATL-879',
|
||||
title: 'Suches & Wolf Pen Shakedown',
|
||||
scheduledStartAt: 'Sep 27 · 8:30 AM',
|
||||
distanceMiles: 95,
|
||||
durationString: '2h 30m',
|
||||
paceClassification: 'Sport Pace',
|
||||
roadCaptainName: 'Marcus Vance',
|
||||
roadCaptainMotorcycle: 'Ducati Panigale V4',
|
||||
sweepName: 'Elena Rostova',
|
||||
sweepStatus: 'assigned',
|
||||
confirmedCount: 18,
|
||||
pendingCount: 0,
|
||||
capacity: 20,
|
||||
routeId: 'rt-suches-wolf-pen-02',
|
||||
status: 'completed',
|
||||
description: 'Technical mountain shakedown ride testing sensor fusion lean angle and paceline spacing.',
|
||||
},
|
||||
];
|
||||
|
||||
export const CANONICAL_FOUNDER_CLUB = FIXTURE_FOUNDER_CLUB;
|
||||
export const CANONICAL_METRICS = FIXTURE_METRICS;
|
||||
export const CANONICAL_UPCOMING_RIDES = FIXTURE_UPCOMING_RIDES;
|
||||
export const CANONICAL_PAST_RIDES = FIXTURE_PAST_RIDES;
|
||||
export const CANONICAL_PENDING_APPLICATIONS = FIXTURE_PENDING_APPLICATIONS;
|
||||
|
||||
|
|
|
|||
|
|
@ -70,8 +70,9 @@ const {
|
|||
approveClubApplication,
|
||||
declineClubApplication,
|
||||
validateClubImageUrl,
|
||||
fetchClubRides,
|
||||
} = clubData;
|
||||
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
|
||||
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS, FIXTURE_UPCOMING_RIDES, FIXTURE_PAST_RIDES } = clubFixtures;
|
||||
|
||||
// =========================================================================
|
||||
// TEST 1: Role-Based Portal Access (Owner vs Admin vs Member vs Unauthenticated)
|
||||
|
|
@ -293,17 +294,16 @@ async function runAllTests() {
|
|||
console.log(' PASS: Founder identity contract verified (ownerUid is identity only; profile read via /public_profiles/{ownerUid}; conservative fallback).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 12: FP-1.3 Club Ride Association Contract (GroupRide.clubId == club.id)
|
||||
// TEST 12: FP-1.3 Club Ride Association Architecture Status
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-1.3 Canonical Club Ride Association (GroupRide.clubId == club.id)...');
|
||||
console.log('Testing: FP-1.3 Club Ride Association Architecture Status...');
|
||||
|
||||
// Verify canonical query in club-data.ts
|
||||
assert(clubDataSrc.includes("where('clubId', '==', resolvedClubId)"), 'Upcoming rides must query strictly where clubId == resolvedClubId');
|
||||
assert(typesSrc.includes('Authoritative link: GroupRide.clubId == club.id'), 'Upcoming rides type must document canonical link');
|
||||
assert(typesSrc.includes('Affiliation is NEVER inferred from accepted rosters'), 'Must document non-inference from rosters');
|
||||
assert(typesSrc.includes('Production GroupRide documents support optional immutable clubId affiliation'), 'Types must document live optional GroupRide.clubId support');
|
||||
assert(typesSrc.includes('GroupRide.clubId == club.id'), 'Types must document the canonical Club affiliation link');
|
||||
assert(typesSrc.includes('Broad client-side Club discovery remains deferred for mutual-block privacy'), 'Types must document the deferred discovery boundary');
|
||||
assert(typesSrc.includes('ClubMember != GroupRideParticipant'), 'Must document ClubMember != GroupRideParticipant invariant');
|
||||
|
||||
console.log(' PASS: Club Ride Association strictly bound to GroupRide.clubId == club.id (never inferred from rosters/accessUids).');
|
||||
console.log(' PASS: Club affiliation is documented as live while broad privacy-preserving discovery remains deferred.');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 13: FP-1.3 Route Distance & Duration Storage Contract
|
||||
|
|
@ -947,7 +947,98 @@ async function runAllTests() {
|
|||
|
||||
console.log(' PASS: Ranks-Roles canManageSettings guard verified (owner/admin allowed, member/unauth denied, demo-preview preserved); HTTPS image URL invariant enforced in app layer and Firestore rules (https accepted, blank clears, http/data/blob/javascript/malformed rejected).');
|
||||
|
||||
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (24/24) ---');
|
||||
// =========================================================================
|
||||
// TEST 25: FP-16 Clubs Rides Management Suite (Truthfulness Hardening)
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-16 Clubs Rides Management Suite (Truthfulness Hardening)...');
|
||||
|
||||
const ridesPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/rides/page.tsx'), 'utf8');
|
||||
const founderRidesSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-rides.tsx'), 'utf8');
|
||||
const clubRideCardSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/club-ride-card.tsx'), 'utf8');
|
||||
const clubFixturesSrc = fs.readFileSync(path.resolve(__dirname, '../lib/club-fixtures.ts'), 'utf8');
|
||||
|
||||
// A. Route exists and reuses FounderPortalShell with activeNav="rides"
|
||||
assert(fs.existsSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/rides/page.tsx')), 'Rides page route must exist');
|
||||
assert(ridesPageSrc.includes('<FounderPortalShell'), 'Rides page must reuse FounderPortalShell');
|
||||
assert(ridesPageSrc.includes('activeNav="rides"'), 'Rides page must pass activeNav="rides"');
|
||||
|
||||
// B. Owner can access
|
||||
assert.equal(ownerPerms.canPlanRides, true, 'Owner has canPlanRides authority');
|
||||
assert.equal(ownerPerms.canManageSettings, true, 'Owner has canManageSettings authority');
|
||||
|
||||
// C. Admin can access
|
||||
assert.equal(adminPerms.canPlanRides, true, 'Admin has canPlanRides authority');
|
||||
assert.equal(adminPerms.canManageSettings, true, 'Admin has canManageSettings authority');
|
||||
|
||||
// D. Member denied management access
|
||||
assert.equal(memberPerms.canPlanRides, false, 'Member must not have canPlanRides authority');
|
||||
assert.equal(memberPerms.canManageSettings, false, 'Member must not have canManageSettings authority');
|
||||
assert(ridesPageSrc.includes('Management Access Restricted'), 'Rides page must guard against unauthorized members');
|
||||
|
||||
// E. Unauthenticated denied
|
||||
assert.equal(unauthPerms.isAuthenticated, false, 'Unauthenticated user has isAuthenticated = false');
|
||||
assert.equal(unauthPerms.canPlanRides, false, 'Unauthenticated user has canPlanRides = false');
|
||||
assert(ridesPageSrc.includes('Authentication Required'), 'Rides page must guard against unauthenticated users');
|
||||
|
||||
// F. Production rides page renders an explicit discovery-pending state, never a false empty schedule
|
||||
assert(ridesPageSrc.includes('Club Rides Discovery Pending'), 'Production rides page must display Club Rides Discovery Pending notice');
|
||||
assert(ridesPageSrc.includes('DISCOVERY PENDING'), 'Production rides page must display DISCOVERY PENDING badge');
|
||||
assert(ridesPageSrc.includes('Club-affiliated Group Rides can now be created in the mobile application'), 'Must truthfully describe current mobile capability');
|
||||
|
||||
// G. fetchClubRides must not issue an unsupported broad production query
|
||||
const fetchRidesBody = clubDataSrc.substring(clubDataSrc.lastIndexOf('export async function fetchClubRides'));
|
||||
assert(!fetchRidesBody.includes("where('clubId'"), 'Scaffold fetchClubRides must not issue a broad client-side clubId query');
|
||||
assert(fetchRidesBody.includes('Club ride discovery is not available until a privacy-preserving production read path is implemented.'), 'Production discovery must fail explicitly');
|
||||
|
||||
// H. Action hardening: All unsupported actions are disabled with explanatory tooltips
|
||||
assert(founderRidesSrc.includes('title="Founder Portal ride planning is not wired yet"'), 'Plan Club Ride must be disabled with tooltip');
|
||||
assert(founderRidesSrc.includes('SOON'), 'Plan Club Ride must have SOON badge');
|
||||
assert(clubRideCardSrc.includes('title="Ride management is planned for a future release"'), 'Manage Ride must be disabled');
|
||||
assert(clubRideCardSrc.includes('title="Ride details are planned for a future release"'), 'View Ride must be disabled');
|
||||
assert(clubRideCardSrc.includes('title="Route navigation is planned for a future release"'), 'View Route must be disabled');
|
||||
assert(clubRideCardSrc.includes('title="Member roster view is planned for a future release"'), 'View Roster must be disabled');
|
||||
|
||||
// I. Model corrections: completedAt removed, completion uses status == completed, routeDistanceMeters is straight-line
|
||||
assert(!typesSrc.includes('completedAt'), 'types.ts must not define completedAt');
|
||||
assert(!clubFixturesSrc.includes('completedAt'), 'club-fixtures.ts must not contain completedAt');
|
||||
assert(typesSrc.includes("Completion is indicated strictly by status == 'completed'"), 'types.ts must document completion via status');
|
||||
assert(typesSrc.includes('routeDistanceMeters is a STRAIGHT-LINE snapshot'), 'types.ts must document straight-line snapshot');
|
||||
assert(clubRideCardSrc.includes('Straight-line distance'), 'club-ride-card.tsx must label distance as Straight-line distance');
|
||||
|
||||
// J. Location privacy invariant (ClubMember != GroupRideParticipant)
|
||||
assert(founderRidesSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in FounderRides');
|
||||
assert(ridesPageSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in rides page');
|
||||
|
||||
// K. Demo preview development gating
|
||||
assert(fetchRidesBody.includes("options?.allowDemoPreview && process.env.NODE_ENV === 'development'"), 'fetchClubRides must gate demo preview to development');
|
||||
assert(ridesPageSrc.includes("searchParams?.get('preview') === 'demo'"), 'rides page must require ?preview=demo for demo mode');
|
||||
|
||||
// L. Production fixture isolation (never falls back to demo data in production)
|
||||
let ridesProdThrew = false;
|
||||
try {
|
||||
process.env.NODE_ENV = 'production';
|
||||
await fetchClubRides('', '');
|
||||
} catch {
|
||||
ridesProdThrew = true;
|
||||
} finally {
|
||||
process.env.NODE_ENV = 'test';
|
||||
}
|
||||
assert.equal(ridesProdThrew, true, 'fetchClubRides must reject empty clubId in production without fallback');
|
||||
|
||||
// M. Sidebar routing & TopBar breadcrumb
|
||||
assert(sidebarSrc.includes("if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`;"), 'FounderSidebar must route to rides');
|
||||
assert(sidebarSrc.includes("isRides = item.id === 'rides';"), 'FounderSidebar must treat rides as subroute');
|
||||
assert(topBarSrc.includes("activeNav === 'rides'"), 'FounderTopBar must handle rides activeNav');
|
||||
assert(topBarSrc.includes('RIDES'), 'FounderTopBar must include RIDES breadcrumb title');
|
||||
|
||||
// N. Fixture past rides present and valid
|
||||
assert(Array.isArray(FIXTURE_PAST_RIDES), 'FIXTURE_PAST_RIDES must be an array');
|
||||
assert(FIXTURE_PAST_RIDES.length > 0, 'FIXTURE_PAST_RIDES must not be empty');
|
||||
assert.equal(FIXTURE_PAST_RIDES[0].status, 'completed', 'Past rides must have completed status');
|
||||
|
||||
console.log(' PASS: FP-16 Tests A through N verified (Route exists, Owner/Admin access, Member denial, Unauth denial, discovery-pending production state, unsupported broad query absent, actions disabled, model corrections, privacy notice, dev-gated preview, production fixture isolation, Sidebar/TopBar, fixtures valid).');
|
||||
|
||||
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (25/25) ---');
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue