feat: add founder club profile and sub-routing foundation

This commit is contained in:
Developer 2026-09-09 07:06:45 -04:00
parent 912b4d2a45
commit 5eb47e8c09
7 changed files with 2468 additions and 0 deletions

View File

@ -0,0 +1,152 @@
'use client';
import React, { useEffect, useState, use } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
fetchFounderClubContext,
FounderClubContext,
sanitizePortalError,
} from '../../../../../lib/club-data';
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
import { FounderProfileEditor } from '../../../../../components/founder-portal/founder-profile-editor';
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 ClubFounderProfilePage({ params }: PageProps) {
const { clubId } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const [context, setContext] = useState<FounderClubContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [unauthenticated, setUnauthenticated] = useState(false);
const [unauthorized, setUnauthorized] = useState(false);
async function loadData() {
setLoading(true);
setError(null);
setUnauthenticated(false);
setUnauthorized(false);
try {
await firebaseAuth.authStateReady();
const currentUser = firebaseAuth.currentUser;
const isDemoPreview = searchParams?.get('preview') === 'demo';
// 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: Only Owner and Admin can manage Club Profile
if (!isDemoPreview && !ctx.permissions.canManageSettings) {
setUnauthorized(true);
setLoading(false);
return;
}
setContext(ctx);
} catch (err) {
setError(sanitizePortalError(err, "Club profile couldn't be loaded."));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData();
}, [clubId]);
if (loading) {
return (
<div className="min-h-screen bg-surface p-6">
<OverviewSkeleton />
</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 profile settings."
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 access Club Profile settings."
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="Profile Unavailable"
message={error ?? "Club profile couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
);
}
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="profile"
pendingApplicationsCount={4}
upcomingRidesCount={2}
>
<FounderProfileEditor
club={context.club}
permissions={context.permissions}
callerUid={context.currentUser.uid}
isDemoFixture={context.isDemoFixture}
onProfileUpdated={(updated) => {
setContext((prev) =>
prev
? {
...prev,
club: {
...prev.club,
...updated,
},
}
: null
);
}}
/>
</FounderPortalShell>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import React, { useState } from 'react';
import { Club, UserPortalPermissions, ClubRole } from './types';
import { FounderSidebar } from './founder-sidebar';
import { FounderTopBar } from './founder-top-bar';
interface FounderPortalShellProps {
club: Club;
permissions: UserPortalPermissions;
activeNav?: string;
onNavSelect?: (nav: string) => void;
onPlanRide?: () => void;
onPostAnnouncement?: () => void;
onSwitchRole?: (role: ClubRole) => void;
onSwitchClub?: () => void;
pendingApplicationsCount?: number;
upcomingRidesCount?: number;
children: React.ReactNode;
}
export function FounderPortalShell({
club,
permissions,
activeNav = 'overview',
onNavSelect,
onPlanRide,
onPostAnnouncement,
onSwitchRole,
onSwitchClub,
pendingApplicationsCount = 4,
upcomingRidesCount = 2,
children,
}: FounderPortalShellProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-surface font-body text-sm text-on-surface antialiased selection:bg-primary selection:text-white">
{/* Sidebar (Desktop persistent + Mobile off-canvas drawer) */}
<FounderSidebar
club={club}
permissions={permissions}
activeNav={activeNav}
onNavSelect={onNavSelect}
pendingApplicationsCount={pendingApplicationsCount}
upcomingRidesCount={upcomingRidesCount}
isOpenOnMobile={mobileSidebarOpen}
onCloseMobile={() => setMobileSidebarOpen(false)}
/>
{/* Main Layout Area */}
<div className="lg:pl-72 flex flex-col min-h-screen">
{/* Top App Bar */}
<FounderTopBar
club={club}
permissions={permissions}
activeNav={activeNav}
onToggleMobileSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)}
onPlanRide={onPlanRide}
onPostAnnouncement={onPostAnnouncement}
onSwitchRole={onSwitchRole}
onSwitchClub={onSwitchClub}
/>
{/* Content Body Container */}
<main className="relative pt-20 px-4 sm:px-6 py-6 w-full max-w-[1440px] mx-auto flex-1">
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,552 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { Club, UserPortalPermissions } from './types';
import { updateClubProfile, sanitizePortalError } from '../../lib/club-data';
interface FounderProfileEditorProps {
club: Club;
permissions: UserPortalPermissions;
callerUid?: string;
isDemoFixture?: boolean;
onProfileUpdated?: (updated: Partial<Club>) => void;
}
export function FounderProfileEditor({
club,
permissions,
callerUid,
isDemoFixture = false,
onProfileUpdated,
}: FounderProfileEditorProps) {
// Form fields matching Section 1 & 2
const [name, setName] = useState(club.name || '');
const [description, setDescription] = useState(club.description || '');
const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
// Save states
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Validation
const trimmedName = name.trim();
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
const isDescValid = description.trim().length <= 500;
const isRegionValid = homeRegion.trim().length <= 100;
const isFocusValid = motorcycleFocus.trim().length <= 100;
const canSave = isNameValid && isDescValid && isRegionValid && isFocusValid && !isSaving && permissions.canManageSettings;
const isDirty =
trimmedName !== (club.name || '').trim() ||
description.trim() !== (club.description || '').trim() ||
homeRegion.trim() !== (club.homeRegion || '').trim() ||
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim();
// Short initials for preview emblem
const initials = name
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase())
.slice(0, 3)
.join('') || 'MSR';
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!canSave) return;
setIsSaving(true);
setSaveStatus('idle');
setErrorMessage(null);
try {
if (isDemoFixture) {
// Simulated latency in development demo preview mode
await new Promise((resolve) => setTimeout(resolve, 600));
setSaveStatus('success');
onProfileUpdated?.({
name: trimmedName,
description: description.trim(),
homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(),
});
} else {
if (!callerUid) {
throw new Error('Authentication required to save profile.');
}
await updateClubProfile(club.id, callerUid, {
name: trimmedName,
description: description.trim(),
homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(),
});
setSaveStatus('success');
onProfileUpdated?.({
name: trimmedName,
description: description.trim(),
homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(),
});
}
} catch (err) {
setSaveStatus('error');
setErrorMessage(sanitizePortalError(err, 'Failed to update club profile. Please try again.'));
} finally {
setIsSaving(false);
}
}
return (
<div className="space-y-6 pb-12">
{/* 3. PAGE HEADER */}
<section className="border-b border-outline-variant pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-secondary tracking-widest uppercase bg-surface-container px-2 py-0.5 rounded border border-secondary/30">
PRECISION, NOT AGGRESSION
</span>
<span className="font-mono text-xs text-outline tracking-wider uppercase">
// {homeRegion ? `${homeRegion.toUpperCase()} ZONE` : 'REGIONAL SQUADRON'}
</span>
</div>
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-on-surface mt-2 uppercase tracking-tight">
CLUB PROFILE
</h1>
<p className="font-body text-sm text-on-surface-variant mt-1">
Manage public-facing club identity, branding presentation, and discovery presence.
</p>
</div>
</section>
{/* 4. TOP 4 METRIC CARDS */}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Club Name */}
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
<div className="flex items-center justify-between">
<span className="font-mono text-[11px] uppercase text-outline">CLUB NAME</span>
<span className="w-1.5 h-1.5 bg-secondary rounded-full" />
</div>
<div className="mt-2">
<div className="font-headline text-sm font-semibold text-on-surface truncate">{name || club.name}</div>
<div className="font-mono text-[11px] text-secondary mt-1">3-50 chars validated</div>
</div>
</div>
{/* Card 2: Home Region */}
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
<div className="flex items-center justify-between">
<span className="font-mono text-[11px] uppercase text-outline">HOME REGION</span>
<span className="material-symbols-outlined text-outline text-sm">location_on</span>
</div>
<div className="mt-2">
<div className="font-headline text-sm font-semibold text-on-surface truncate">
{homeRegion || 'Not specified'}
</div>
<div className="font-mono text-[11px] text-outline mt-1">Home Region</div>
</div>
</div>
{/* Card 3: Motorcycle Focus */}
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
<div className="flex items-center justify-between">
<span className="font-mono text-[11px] uppercase text-outline">MOTORCYCLE FOCUS</span>
<span className="material-symbols-outlined text-outline text-sm">sports_motorsports</span>
</div>
<div className="mt-2">
<div className="font-headline text-sm font-semibold text-on-surface truncate">
{motorcycleFocus || 'General'}
</div>
<div className="font-mono text-[11px] text-outline mt-1">Motorcycle Focus</div>
</div>
</div>
{/* Card 4: Discovery Presence */}
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
<div className="flex items-center justify-between">
<span className="font-mono text-[11px] uppercase text-outline">DISCOVERY PRESENCE</span>
<span
className={`w-2 h-2 rounded-full ${
club.visibility === 'public' ? 'bg-emerald-400' : club.visibility === 'unlisted' ? 'bg-amber-400' : 'bg-outline'
}`}
/>
</div>
<div className="mt-2">
<div
className={`font-headline text-sm font-semibold uppercase ${
club.visibility === 'public' ? 'text-emerald-400' : club.visibility === 'unlisted' ? 'text-amber-400' : 'text-on-surface'
}`}
>
{club.visibility}
</div>
<div className="font-mono text-[11px] text-outline mt-1">
{club.visibility === 'public'
? 'Listed in public club index & directory'
: club.visibility === 'unlisted'
? 'Accessible via direct handle only'
: 'Restricted private club'}
</div>
</div>
</div>
</section>
{/* 5. MAIN WORKSPACE (12-Column Grid) */}
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT MAIN (8 cols) */}
<div className="lg:col-span-8 space-y-6">
{/* Status Feedback Banner */}
{saveStatus === 'success' && (
<div className="p-4 rounded bg-secondary/10 border border-secondary/30 flex items-center gap-3 text-secondary">
<span className="material-symbols-outlined text-xl">check_circle</span>
<div className="text-xs font-body font-medium">
Club profile changes have been saved successfully.
</div>
</div>
)}
{saveStatus === 'error' && errorMessage && (
<div className="p-4 rounded bg-error-container border border-error/30 flex items-center gap-3 text-error">
<span className="material-symbols-outlined text-xl">error</span>
<div className="text-xs font-body font-medium">{errorMessage}</div>
</div>
)}
{/* SECTION 1: PUBLIC CLUB IDENTITY */}
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-secondary text-lg">badge</span>
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
SECTION 1: PUBLIC CLUB IDENTITY
</h2>
</div>
</div>
{/* Club Name */}
<div className="space-y-1.5">
<label htmlFor="club-name-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
Club Name
</label>
<div className="relative">
<input
id="club-name-input"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="e.g. MotorSignal Riders Atlanta"
className="w-full bg-surface-dim border border-outline-variant rounded px-3 py-2 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors"
/>
<span
className={`absolute right-3 top-2.5 font-mono text-[10px] uppercase font-semibold ${
isNameValid ? 'text-secondary' : 'text-error'
}`}
>
{isNameValid ? 'VALID' : `${trimmedName.length}/50`}
</span>
</div>
<p className="font-body text-xs text-outline">
3-50 characters. Displayed on public discovery, ride manifests, and club roster.
</p>
</div>
{/* Club Handle / Identifier (Locked) */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="font-mono text-[11px] text-outline uppercase font-medium">
Club Handle / Identifier
</label>
<span className="font-mono text-[10px] px-1.5 py-0.5 bg-surface-container-highest text-outline border border-outline-variant rounded">
IMMUTABLE
</span>
</div>
<div className="relative flex items-center">
<span className="material-symbols-outlined absolute left-3 text-outline text-base">lock</span>
<input
type="text"
value={`@${club.slug}`}
readOnly
disabled
className="w-full bg-surface-container-lowest border border-outline-variant rounded pl-9 pr-3 py-2 text-xs font-mono text-on-surface-variant cursor-not-allowed opacity-90"
/>
</div>
<p className="font-body text-xs text-outline">
Club handles are permanently reserved and cannot be renamed or reassigned.
</p>
</div>
{/* Club Description */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label htmlFor="club-desc-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
Club Description
</label>
<span className="font-mono text-[10px] text-outline">
{description.length}/500
</span>
</div>
<textarea
id="club-desc-input"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="Describe your club's ethos, riding discipline, and mission..."
className="w-full bg-surface-dim border border-outline-variant rounded p-3 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors leading-relaxed"
/>
<p className="font-body text-xs text-outline">
Displayed on your public Club profile.
</p>
</div>
</div>
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-secondary text-lg">explore</span>
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
SECTION 2: REGIONAL &amp; MOTORCYCLE FOCUS
</h2>
</div>
<span className="font-mono text-[11px] text-outline">GEO / DISCIPLINE</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Home Region */}
<div className="space-y-1.5">
<label htmlFor="club-region-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
Home Region
</label>
<input
id="club-region-input"
type="text"
value={homeRegion}
onChange={(e) => setHomeRegion(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="e.g. Atlanta, Georgia"
className="w-full bg-surface-dim border border-outline-variant rounded px-3 py-2 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors"
/>
<p className="font-body text-xs text-outline">Primary area where your Club is based.</p>
</div>
{/* Motorcycle Focus */}
<div className="space-y-1.5">
<label htmlFor="club-focus-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
Motorcycle Focus
</label>
<input
id="club-focus-input"
type="text"
value={motorcycleFocus}
onChange={(e) => setMotorcycleFocus(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="e.g. Sport / Touring"
className="w-full bg-surface-dim border border-outline-variant rounded px-3 py-2 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors"
/>
<p className="font-body text-xs text-outline">
Main riding style or motorcycle focus for your Club.
</p>
</div>
</div>
</div>
{/* SECTION 3: LOGO & BANNER (Presentation Only) */}
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-secondary text-lg">palette</span>
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
SECTION 3: LOGO &amp; BANNER
</h2>
</div>
<span className="font-mono text-[11px] text-outline">VISUAL ASSETS</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Logo Presentation Card */}
<div className="bg-surface-dim border border-outline-variant rounded p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-[11px] text-outline uppercase">
<span>CLUB EMBLEM</span>
</div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-outline-variant rounded flex flex-col items-center justify-center relative overflow-hidden">
{club.logoUrl ? (
<img src={club.logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain" />
) : (
<div className="w-20 h-20 rounded-lg border-2 border-secondary bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative">
<span className="material-symbols-outlined text-secondary text-2xl">two_wheeler</span>
<span className="font-headline text-secondary font-bold text-xs tracking-widest mt-1">
{initials}
</span>
</div>
)}
</div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-outline">
<span className="material-symbols-outlined text-xs">info</span>
<span>Logo and banner editing is not available yet.</span>
</div>
</div>
{/* Banner Presentation Card */}
<div className="bg-surface-dim border border-outline-variant rounded p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-[11px] text-outline uppercase">
<span>BANNER PRESENTATION</span>
</div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-outline-variant rounded relative overflow-hidden flex items-center justify-center">
{club.bannerUrl ? (
<img
src={club.bannerUrl}
alt="Club banner presentation"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-surface-container-low flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(#3491ff_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
<div className="flex items-center gap-2 font-mono text-xs text-outline z-10">
<span className="material-symbols-outlined text-base text-secondary">image</span>
<span className="uppercase tracking-wider font-semibold">{club.name}</span>
</div>
</div>
)}
</div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-outline">
<span className="material-symbols-outlined text-xs">info</span>
<span>Logo and banner editing is not available yet.</span>
</div>
</div>
</div>
</div>
{/* ACTION DOCKED FOOTER */}
<div className="bg-surface-container-low border border-outline-variant rounded p-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 font-body text-xs text-outline">
<span className="material-symbols-outlined text-secondary text-base">check_circle</span>
<span>Saves public club profile information.</span>
</div>
<button
type="submit"
disabled={!canSave || (!isDirty && saveStatus !== 'error')}
className={`w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-2.5 rounded font-headline text-xs font-semibold uppercase tracking-wider transition-all duration-150 ${
canSave && (isDirty || saveStatus === 'error')
? 'bg-primary-container hover:bg-primary text-on-primary active:scale-[0.99] shadow-[0_0_15px_rgba(52,145,255,0.3)] cursor-pointer'
: 'bg-surface-container-high text-outline cursor-not-allowed opacity-60'
}`}
>
{isSaving ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span>Saving Changes...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base">check</span>
<span>Save Profile Changes</span>
</>
)}
</button>
</div>
</div>
{/* RIGHT RAIL (4 cols) */}
<div className="lg:col-span-4 space-y-6">
{/* CARD A: PUBLIC DIRECTORY PREVIEW (Live Simulation) */}
<div className="bg-surface-container-low border border-outline-variant rounded p-4 space-y-4">
<div className="flex items-center justify-between border-b border-outline-variant pb-2.5">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-secondary text-base">visibility</span>
<span className="font-headline text-xs text-on-surface font-semibold uppercase">
DIRECTORY PREVIEW
</span>
</div>
<span className="font-mono text-[10px] bg-surface-container-high text-secondary px-2 py-0.5 rounded border border-secondary/30">
LIVE SIMULATION
</span>
</div>
{/* Embedded Public Card Mockup (IDENTITY ONLY, ZERO METRICS) */}
<div className="bg-surface-container border border-outline-variant rounded overflow-hidden shadow-lg">
{/* Banner Mockup */}
<div className="h-24 bg-surface-container-high relative overflow-hidden">
{club.bannerUrl ? (
<img
src={club.bannerUrl}
alt="Directory banner preview"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-surface-container-low flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(#3491ff_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-surface-container" />
</div>
<div className="p-4 pt-0 relative">
{/* Emblem overlapping */}
<div className="-mt-8 w-14 h-14 rounded bg-surface-dim border-2 border-secondary flex flex-col items-center justify-center text-secondary shadow-md">
<span className="material-symbols-outlined text-xl">two_wheeler</span>
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
</div>
<div className="mt-2 flex items-center">
<h3 className="font-headline text-sm text-on-surface font-bold truncate">
{name || club.name}
</h3>
</div>
<div className="font-mono text-[11px] text-outline mt-0.5">@{club.slug}</div>
<div className="mt-2 inline-flex items-center gap-1.5 font-mono text-[10px] text-secondary bg-surface-container-highest px-2 py-0.5 rounded">
<span className="material-symbols-outlined text-xs">location_on</span>
<span className="truncate">
{homeRegion || 'Global'} • {motorcycleFocus || 'General'}
</span>
</div>
<p className="mt-3 font-body text-xs text-on-surface-variant line-clamp-2 leading-relaxed">
{description || 'No club description provided yet.'}
</p>
</div>
</div>
<p className="font-mono text-[10px] text-outline text-center">
Rendered in standard public directory feed (1:1 responsive layout)
</p>
</div>
{/* CARD B: ACCESS & DISCOVERY BOUNDARY */}
<div className="bg-surface-container-low border border-outline-variant rounded p-4 space-y-3">
<div className="flex items-center gap-2 text-on-surface border-b border-outline-variant pb-2.5">
<span className="material-symbols-outlined text-secondary text-base">admin_panel_settings</span>
<h3 className="font-headline text-xs uppercase font-semibold">ACCESS CONTROLS</h3>
</div>
<p className="font-body text-xs text-on-surface-variant leading-relaxed">
Access settings (Visibility &amp; Admission Policy) are managed separately under Club Settings.
</p>
<div className="pt-1 flex items-center">
<span className="inline-flex items-center gap-1.5 text-secondary font-mono text-xs cursor-default">
<span>Manage Access Settings</span>
<span className="material-symbols-outlined text-sm">arrow_forward</span>
</span>
</div>
</div>
{/* CARD C: PRIVACY CONTRACT */}
<div className="bg-surface-dim border border-outline-variant rounded p-4 space-y-3">
<div className="flex items-center gap-2 text-secondary">
<span className="material-symbols-outlined text-base">lock</span>
<span className="font-mono text-xs uppercase font-bold tracking-wider">PRIVACY CONTRACT</span>
</div>
<blockquote className="font-body text-xs italic text-on-surface border-l-2 border-secondary pl-3 py-1 bg-surface-container-lowest/50 rounded-r leading-relaxed">
&ldquo;Public Club Profile is organizational identity only. Member telemetry, GPS tracks, personal ride logs, and private account data are not included.&rdquo;
</blockquote>
<p className="font-mono text-[10px] text-outline">
Telemetry and member activity are strictly isolated.
</p>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,280 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Club, UserPortalPermissions } from './types';
interface FounderSidebarProps {
club: Club;
permissions: UserPortalPermissions;
activeNav?: string;
onNavSelect?: (nav: string) => void;
pendingApplicationsCount?: number;
upcomingRidesCount?: number;
isOpenOnMobile?: boolean;
onCloseMobile?: () => void;
}
export function FounderSidebar({
club,
permissions,
activeNav = 'overview',
onNavSelect,
pendingApplicationsCount = 4,
upcomingRidesCount = 2,
isOpenOnMobile = false,
onCloseMobile,
}: FounderSidebarProps) {
const searchParams = useSearchParams();
const isDemo = searchParams?.get('preview') === 'demo';
const queryParam = isDemo ? '?preview=demo' : '';
const clubParam = club.slug || club.id;
const shortCode = club.name
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase())
.slice(0, 3)
.join('') || 'ATL';
const navItemsTop = [
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary' },
{ id: 'profile', label: 'Club Profile', icon: 'shield' },
{ id: 'members', label: 'Members', icon: 'group', badge: club.memberCount.toString(), badgeType: 'neutral' },
{
id: 'applications',
label: 'Applications',
icon: 'person_add',
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} ALERT` : undefined,
badgeType: 'error',
},
{ id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech' },
];
const navItemsMiddle = [
{
id: 'rides',
label: 'Rides',
icon: 'route',
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount} ACTIVE` : undefined,
badgeType: 'secondary',
},
{ id: 'routes', label: 'Routes', icon: 'map' },
{ id: 'wall', label: 'Wall', icon: 'forum' },
{ id: 'chat', label: 'Chat', icon: 'chat' },
{ id: 'stats', label: 'Stats', icon: 'query_stats' },
];
const navItemsBottom = [
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: '1', badgeType: 'neutral' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
function getRouteHref(id: string) {
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
return undefined;
}
function handleItemClick(id: string) {
onNavSelect?.(id);
onCloseMobile?.();
}
function renderNavItem(item: { id: string; label: string; icon: string; badge?: string; badgeType?: string }) {
const isActive = activeNav === item.id;
const href = getRouteHref(item.id);
const isProfile = item.id === 'profile';
const content = (
<>
<div className="flex items-center gap-2.5">
<span
className={`material-symbols-outlined text-[18px] ${
isActive && isProfile ? 'text-secondary' : ''
}`}
style={isActive && isProfile ? { fontVariationSettings: "'FILL' 1" } : undefined}
>
{item.icon}
</span>
<span className={isActive && isProfile ? 'text-secondary font-semibold' : ''}>
{item.label}
</span>
</div>
{item.badge && (
<span
className={`font-mono text-[10px] px-1.5 py-0.5 rounded ${
item.badgeType === 'primary' && isActive
? 'bg-white/20 text-white font-medium'
: item.badgeType === 'error'
? 'bg-error-container text-on-error-container font-semibold border border-error/30'
: item.badgeType === 'secondary'
? 'bg-secondary/10 text-secondary border border-secondary/30 font-semibold'
: 'bg-surface-container-high text-on-surface-variant border border-outline-variant'
}`}
>
{item.badge}
</span>
)}
{isActive && isProfile && !item.badge && (
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
)}
</>
);
const baseClasses = `w-full flex items-center justify-between px-3 py-2 rounded transition-colors text-xs ${
isActive
? isProfile
? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'
: 'bg-primary text-white font-medium'
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface font-normal'
}`;
if (href) {
return (
<Link
key={item.id}
href={href}
onClick={() => {
onNavSelect?.(item.id);
onCloseMobile?.();
}}
className={baseClasses}
>
{content}
</Link>
);
}
return (
<button
key={item.id}
onClick={() => handleItemClick(item.id)}
className={baseClasses}
>
{content}
</button>
);
}
return (
<>
{/* Mobile Backdrop */}
{isOpenOnMobile && (
<div
onClick={onCloseMobile}
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40 lg:hidden"
aria-hidden="true"
/>
)}
<aside
className={`fixed left-0 top-0 h-full w-72 bg-surface-container-lowest z-50 flex flex-col justify-between border-r border-outline-variant transition-transform duration-200 ease-in-out ${
isOpenOnMobile ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
}`}
aria-label="Founder Portal Sidebar"
>
<div className="flex flex-col flex-1 min-h-0">
{/* Brand Header */}
<div className="h-16 px-5 flex items-center justify-between border-b border-outline-variant bg-surface-container-lowest">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-8 h-8 rounded bg-surface-container-high border border-outline-variant flex items-center justify-center text-primary group-hover:border-primary/50 transition-colors">
<span className="material-symbols-outlined text-[20px]">two_wheeler</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-headline font-semibold text-[15px] tracking-tight text-on-surface">
MOTORSIGNAL
</span>
<span className="font-mono text-[10px] text-outline px-1.5 py-0.5 rounded bg-surface-container-high border border-outline-variant">
PORTAL
</span>
</div>
<span className="font-mono text-[10px] text-secondary tracking-wider uppercase font-medium">
CLUB MANAGEMENT
</span>
</div>
</Link>
{/* Mobile close button */}
<button
onClick={onCloseMobile}
className="lg:hidden text-outline hover:text-on-surface p-1"
aria-label="Close sidebar"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
{/* Current Club Context */}
<div className="px-5 py-3 border-b border-outline-variant bg-surface-container-low/60">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] text-outline tracking-wider uppercase font-medium">
CURRENT CLUB
</span>
<span className="flex items-center gap-1.5 font-mono text-[10px] text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
ONLINE / SYNCED
</span>
</div>
<div className="mt-1 flex items-center justify-between font-body text-xs text-on-surface">
<span className="truncate font-semibold">{club.name}</span>
<span className="font-mono text-[11px] text-outline ml-2 shrink-0">{shortCode}</span>
</div>
</div>
{/* Navigation Links */}
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
{navItemsTop.map(renderNavItem)}
{/* Divider */}
<div className="pt-2 pb-1 px-3">
<div className="h-px bg-outline-variant/60 w-full" />
</div>
{navItemsMiddle.map(renderNavItem)}
{/* Divider */}
<div className="pt-2 pb-1 px-3">
<div className="h-px bg-outline-variant/60 w-full" />
</div>
{navItemsBottom.map(renderNavItem)}
</nav>
</div>
{/* Bottom Founder Profile */}
<div className="p-3 border-t border-outline-variant bg-surface-container-low">
<div className="flex items-center justify-between p-2 rounded bg-surface-container border border-outline-variant">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded bg-primary flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-white text-[18px]">person</span>
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1">
<span className="font-body text-xs font-semibold text-on-surface truncate">
{club.founderName ?? 'Marcus Vance'}
</span>
<span className="material-symbols-outlined text-[13px] text-secondary shrink-0">
verified
</span>
</div>
<span className="font-mono text-[10px] text-outline truncate">
{permissions.role === 'owner' ? 'Owner & President' : permissions.role.toUpperCase()}
</span>
</div>
</div>
<Link
href="/account"
className="text-outline hover:text-on-surface transition-colors p-1"
title="Account Settings"
>
<span className="material-symbols-outlined text-[18px]">settings</span>
</Link>
</div>
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,166 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Club, UserPortalPermissions, ClubRole } from './types';
interface FounderTopBarProps {
club: Club;
permissions: UserPortalPermissions;
activeNav?: string;
onToggleMobileSidebar?: () => void;
onPlanRide?: () => void;
onPostAnnouncement?: () => void;
onSwitchRole?: (role: ClubRole) => void;
onSwitchClub?: () => void;
}
export function FounderTopBar({
club,
permissions,
activeNav,
onToggleMobileSidebar,
onPlanRide,
onPostAnnouncement,
onSwitchRole,
onSwitchClub,
}: FounderTopBarProps) {
return (
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-outline-variant z-40 px-4 sm:px-6 flex items-center justify-between gap-4">
{/* Left: Mobile hamburger + Breadcrumbs + Switch Club */}
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
{/* Mobile Hamburger Toggle */}
<button
onClick={onToggleMobileSidebar}
className="lg:hidden text-outline hover:text-on-surface p-1 rounded hover:bg-surface-container transition-colors"
aria-label="Open sidebar"
>
<span className="material-symbols-outlined text-[22px]">menu</span>
</button>
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0">
<Link href="/clubs" className="hover:text-on-surface transition-colors">
CLUBS
</Link>
<span>/</span>
<Link
href={`/clubs/${club.slug || club.id}/manage`}
className="text-on-surface font-semibold truncate max-w-[120px] sm:max-w-[200px] md:max-w-none uppercase hover:text-primary transition-colors"
>
{club.name}
</Link>
{activeNav === 'profile' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-secondary font-semibold uppercase">CLUB PROFILE</span>
</>
)}
</div>
<div className="relative flex items-center shrink-0">
<button
onClick={onSwitchClub}
className="flex items-center gap-1.5 px-2.5 py-1 rounded bg-surface-container border border-outline-variant text-on-surface font-body text-xs hover:bg-surface-container-high transition-colors"
>
<span className="material-symbols-outlined text-[15px] text-secondary">swap_horiz</span>
<span className="hidden sm:inline">Switch Club</span>
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span>
</button>
</div>
</div>
{/* Middle: Search Input (desktop/tablet only) */}
<div className="flex-1 max-w-md mx-4 hidden md:block">
<div className="relative">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
search
</span>
<input
className="w-full h-8 pl-9 pr-3 rounded bg-surface-container-low border border-outline-variant text-on-surface placeholder:text-outline/70 font-body text-xs focus:outline-none focus:border-primary transition-colors"
placeholder="Search members, rides, routes..."
type="text"
aria-label="Search members, rides, routes"
/>
</div>
</div>
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */}
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0">
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
{process.env.NODE_ENV === 'development' ? (
<div
className="hidden xl:flex items-center gap-1 px-2 py-0.5 rounded bg-surface-container-high border border-outline-variant text-[11px] font-mono text-outline"
title="Interactive role preview for testing software permissions (Development Only)"
>
<span className="text-outline-variant">DEV PREVIEW:</span>
{(['owner', 'admin', 'member'] as const).map((r) => (
<button
key={r}
onClick={() => onSwitchRole?.(r)}
className={`px-1.5 py-0.5 rounded uppercase font-semibold transition-colors ${
permissions.role === r
? 'bg-primary text-white'
: 'hover:text-on-surface'
}`}
>
{r}
</button>
))}
</div>
) : (
<div
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded bg-surface-container border border-outline-variant text-[11px] font-mono text-outline uppercase font-medium"
title="Authoritative software role"
>
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
<span className="text-on-surface font-semibold">{permissions.role}</span>
</div>
)}
{/* Post Announcement (Owner / Admin only) */}
{permissions.canManageSettings && (
<button
onClick={onPostAnnouncement}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded bg-surface-container border border-outline-variant text-on-surface font-body text-xs font-medium hover:bg-surface-container-high transition-colors"
>
<span className="material-symbols-outlined text-[15px] text-secondary">campaign</span>
<span>Post Announcement</span>
</button>
)}
{/* Plan Club Ride (Owner / Admin only) */}
{permissions.canPlanRides && (
<button
onClick={onPlanRide}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-primary text-white font-body text-xs font-semibold hover:bg-primary-container transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-[15px]">add_road</span>
<span className="hidden sm:inline">Plan Club Ride</span>
</button>
)}
{/* Notifications */}
<div className="relative ml-1">
<button
className="w-8 h-8 rounded bg-surface-container border border-outline-variant flex items-center justify-center text-outline hover:text-on-surface transition-colors"
title="Notifications"
aria-label="Notifications"
>
<span className="material-symbols-outlined text-[18px]">notifications</span>
</button>
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-error" />
</div>
{/* Account Avatar */}
<Link
href="/account"
className="w-7 h-7 rounded bg-primary flex items-center justify-center ml-0.5 hover:ring-2 hover:ring-primary-container transition-all"
title="Account profile"
aria-label="Account profile"
>
<span className="material-symbols-outlined text-white text-[16px]">person</span>
</Link>
</div>
</header>
);
}

667
website/lib/club-data.ts Normal file
View File

@ -0,0 +1,667 @@
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp } from 'firebase/firestore';
import { firestore } from './firebase-client';
import {
Club,
ClubMember,
ClubUpcomingRide,
ClubMetrics,
UserPortalPermissions,
ClubActivityItem,
ClubRole,
} from '../components/founder-portal/types';
import {
FIXTURE_FOUNDER_CLUB,
FIXTURE_METRICS,
FIXTURE_UPCOMING_RIDES,
FIXTURE_PENDING_APPLICATIONS,
} from './club-fixtures';
export interface ManageableClubSummary {
id: string;
name: string;
slug?: string;
role: ClubRole;
displayRank?: string;
memberCount: number;
homeRegion?: string;
logoUrl?: string | null;
}
/**
* Calculates software permissions derived solely from Administrative Role.
* Club Display Rank does NOT grant software administrative access.
*/
export function getPortalPermissions(role: ClubRole, isAuthenticated = true): UserPortalPermissions {
if (!isAuthenticated) {
return {
isAuthenticated: false,
role: 'member',
canManageSettings: false,
canInviteMembers: false,
canApproveApplications: false,
canPlanRides: false,
canTransferOwnership: false,
};
}
const isOwner = role === 'owner';
const isAdmin = role === 'admin' || isOwner;
return {
isAuthenticated: true,
role,
canManageSettings: isAdmin,
canInviteMembers: isAdmin,
canApproveApplications: isAdmin,
canPlanRides: isAdmin,
canTransferOwnership: isOwner,
};
}
/**
* Sanitizes backend errors into closed, user-safe messages.
* Prevents raw exception leakage, Firestore paths, internal codes, or stack traces.
*/
export function sanitizePortalError(error: unknown, fallback = 'Something went wrong. Please try again.'): string {
if (process.env.NODE_ENV !== 'production') {
console.error('[FounderPortal Internal Debug]:', error);
}
if (!error) return fallback;
const errString = String(error);
if (
errString.includes('permission-denied') ||
errString.includes('unauthenticated') ||
errString.includes('PERMISSION_DENIED') ||
errString.includes('unauthorized')
) {
return 'Club information is restricted or requires authentication.';
}
if (errString.includes('not-found') || errString.includes('NOT_FOUND')) {
return 'Club information could not be found.';
}
if (
errString.includes('unavailable') ||
errString.includes('network') ||
errString.includes('DEADLINE_EXCEEDED') ||
errString.includes('UNAVAILABLE')
) {
return "Club information couldn't be loaded. Check your network connection.";
}
// Any internal precondition or unhandled error falls back to generic safe text
return fallback;
}
/**
* Safely fetches club details from Firestore.
* PRODUCTION RUNTIME NEVER SILENTLY FALLS BACK TO DEMO DATA.
* Dev preview is only returned if explicitly opted into in development mode.
*/
export async function fetchClubData(
clubIdOrSlug: string,
callerUid?: string,
options?: { allowDemoPreview?: boolean }
): Promise<{
club: Club;
metrics: ClubMetrics;
upcomingRides: ClubUpcomingRide[];
pendingApplications: ClubMember[];
activities: ClubActivityItem[];
userRole: ClubRole;
userRank: string;
}> {
const normalized = (clubIdOrSlug || '').trim().toLowerCase();
// Explicit opt-in development preview fixture ONLY
if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') {
return {
club: FIXTURE_FOUNDER_CLUB,
metrics: FIXTURE_METRICS,
upcomingRides: FIXTURE_UPCOMING_RIDES,
pendingApplications: FIXTURE_PENDING_APPLICATIONS,
activities: [],
userRole: 'owner',
userRank: 'President',
};
}
if (!normalized) {
throw new Error('not-found');
}
try {
let resolvedClubId = clubIdOrSlug;
// Try finding slug in club_handles first
try {
const handleRef = doc(firestore, 'club_handles', normalized);
const handleSnap = await getDoc(handleRef);
if (handleSnap.exists()) {
resolvedClubId = handleSnap.data().clubId || clubIdOrSlug;
}
} catch {
// Slug lookup failed; proceed to query direct club document
}
const clubRef = doc(firestore, 'clubs', resolvedClubId);
const clubSnap = await getDoc(clubRef);
if (!clubSnap.exists()) {
throw new Error('not-found');
}
const d = clubSnap.data();
if (d.archivedAt) {
throw new Error('not-found');
}
// Authoritative Founder Identity Resolution:
// Club.ownerUid establishes ownership identity only.
// Canonical rider profile lookup: /public_profiles/{ownerUid} -> displayName, username.
// Conservative fallback when unauthenticated, restricted, or private.
// Never persist or expect a redundant 'founderName' field in /clubs/{clubId}.
let founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder';
let founderHandle: string | undefined = undefined;
if (d.ownerUid) {
try {
const ownerProfileSnap = await getDoc(doc(firestore, 'public_profiles', d.ownerUid));
if (ownerProfileSnap.exists()) {
const profileData = ownerProfileSnap.data();
if (callerUid !== d.ownerUid && profileData.displayName) {
founderName = profileData.displayName;
}
if (profileData.username) {
founderHandle = `@${profileData.username}`;
}
}
} catch {
// Point read restricted, blocked, unauthenticated, or unavailable: maintain conservative fallback
}
}
const club: Club = {
id: clubSnap.id,
name: d.name || 'Unnamed Club',
slug: d.slug || normalized,
description: d.description || '',
visibility: d.visibility || 'public',
admissionPolicy: d.admissionPolicy || 'request',
ownerUid: d.ownerUid || '',
memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1,
createdAt: d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : (d.createdAt || new Date().toISOString()),
updatedAt: d.updatedAt?.toDate?.() ? d.updatedAt.toDate().toISOString() : (d.updatedAt || new Date().toISOString()),
archivedAt: d.archivedAt?.toDate?.() ? d.archivedAt.toDate().toISOString() : (d.archivedAt || null),
logoUrl: d.logoUrl || null,
bannerUrl: d.bannerUrl || null,
homeRegion: d.homeRegion || 'Global',
motorcycleFocus: d.motorcycleFocus || 'General',
founderName,
founderHandle,
founderRank: 'Founder',
foundedYear: d.createdAt?.toDate?.() ? String(d.createdAt.toDate().getFullYear()) : '2024',
};
// Determine current user's membership role
let userRole: ClubRole = 'member';
let userRank = 'Member';
if (callerUid) {
if (callerUid === club.ownerUid) {
userRole = 'owner';
userRank = 'President';
} else {
try {
const myMemberSnap = await getDoc(doc(firestore, 'clubs', resolvedClubId, 'members', callerUid));
if (myMemberSnap.exists()) {
const mData = myMemberSnap.data();
if (mData.status === 'active') {
userRole = (mData.role as ClubRole) || 'member';
userRank = mData.displayRank || (userRole === 'admin' ? 'Officer' : 'Member');
}
}
} catch {
// Point read restricted: default to standard member
}
}
}
// Fetch pending applications ONLY if caller is verified owner or admin
let pendingApps: ClubMember[] = [];
if (userRole === 'owner' || userRole === 'admin') {
try {
const appsQuery = query(
collection(firestore, 'clubs', resolvedClubId, 'members'),
where('status', '==', 'requested'),
limit(10)
);
const appsSnap = await getDocs(appsQuery);
pendingApps = appsSnap.docs.map((s) => {
const ad = s.data();
return {
uid: s.id,
clubId: resolvedClubId,
status: 'requested',
role: 'member',
displayName: ad.displayName || ad.handle || 'Applicant',
handle: ad.handle ? `@${ad.handle}` : `@rider_${s.id.slice(0, 6)}`,
motorcycle: ad.motorcycle || undefined,
location: ad.location || undefined,
note: ad.note || undefined,
updatedAt: ad.updatedAt?.toDate?.() ? ad.updatedAt.toDate().toISOString() : new Date().toISOString(),
requestedAt: ad.requestedAt?.toDate?.() ? ad.requestedAt.toDate().toISOString() : new Date().toISOString(),
};
});
} catch {
// Query not permitted or rules constrained: safely empty
}
}
// Authoritative Upcoming Club Rides Query:
// CANONICAL LINK: GroupRide.clubId == club.id
// Strictly unaffiliated from organizer membership, participant lists, accessUids, or live presence.
// PRIVACY INVARIANT: ClubMember != GroupRideParticipant (location sharing requires accepted ride participation).
// ROUTE METRICS: Derived from MotorSignalRoute. Optional road duration/distance fallback to '--'.
let upcomingRides: ClubUpcomingRide[] = [];
try {
const ridesQuery = query(
collection(firestore, 'group_rides'),
where('clubId', '==', resolvedClubId),
limit(10)
);
const ridesSnap = await getDocs(ridesQuery);
upcomingRides = ridesSnap.docs.map((s) => {
const rd = s.data();
return {
id: s.id,
rideIdentifier: rd.rideIdentifier || `RIDE-${s.id.slice(0, 6).toUpperCase()}`,
title: rd.title || 'Untitled Club Ride',
scheduledStartAt: rd.scheduledStartAt?.toDate?.()
? rd.scheduledStartAt.toDate().toLocaleDateString()
: (rd.scheduledStartAt || 'TBD'),
departureAt: rd.departureAt?.toDate?.()
? rd.departureAt.toDate().toLocaleDateString()
: rd.departureAt,
distanceMiles: typeof rd.routeDistanceMeters === 'number'
? Math.round(rd.routeDistanceMeters * 0.000621371)
: null,
durationString: null, // Road duration is optional/transient; '--' if unpopulated
paceClassification: rd.paceClassification || 'Club Paceline',
roadCaptainName: rd.organizerDisplayName || 'Ride Leader',
roadCaptainHandle: rd.organizerHandle ? `@${rd.organizerHandle}` : undefined,
roadCaptainMotorcycle: rd.organizerMotorcycle || null,
sweepName: rd.sweepName || null,
sweepStatus: rd.sweepStatus || 'vacant',
confirmedCount: typeof rd.participantCount === 'number' ? rd.participantCount : 1,
pendingCount: typeof rd.pendingCount === 'number' ? rd.pendingCount : 0,
capacity: rd.participantLimit || null,
briefingTime: rd.briefingTime || null,
routeId: rd.routeId || null,
};
});
} catch {
// Group rides query restricted or index unready: safely empty
}
// Truthful metrics derived strictly from schema:
// No fabricated deltas (+6), active percent (98%), or road marshal counts
const metrics: ClubMetrics = {
members: club.memberCount,
membersActivePercent: null,
membersDeltaMonth: null,
roadMarshalsCount: null,
pendingApplications: pendingApps.length,
upcomingRidesCount: upcomingRides.length,
confirmedRidersTotal: upcomingRides.reduce((sum, r) => sum + r.confirmedCount, 0),
nextRideDate: upcomingRides[0]?.scheduledStartAt || null,
clubMilesThisMonth: null,
completedClubRidesCount: 0,
clubMilesAvailable: false,
};
return {
club,
metrics,
upcomingRides,
pendingApplications: pendingApps,
activities: [],
userRole,
userRank,
};
} catch (error) {
throw new Error(sanitizePortalError(error, "Club information couldn't be loaded."));
}
}
/**
* Authenticated Club Discovery:
* Returns only clubs where caller is club owner (ownerUid == callerUid)
* or active club admin (role == 'admin' in /clubs/{clubId}/members/{callerUid}).
* Ordinary members NEVER see their clubs in the management list.
*/
export async function fetchManageableClubs(callerUid: string): Promise<ManageableClubSummary[]> {
if (!callerUid) return [];
const clubMap = new Map<string, ManageableClubSummary>();
try {
// 1. Query clubs owned by user
const ownedQuery = query(
collection(firestore, 'clubs'),
where('ownerUid', '==', callerUid)
);
const ownedSnap = await getDocs(ownedQuery);
for (const docSnap of ownedSnap.docs) {
const d = docSnap.data();
if (!d.archivedAt) {
clubMap.set(docSnap.id, {
id: docSnap.id,
name: d.name || 'Unnamed Club',
slug: d.slug,
role: 'owner',
displayRank: 'President',
memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1,
homeRegion: d.homeRegion || 'Global',
logoUrl: d.logoUrl || null,
});
}
}
// 2. Query memberships where user is memberUid and role is admin
const membersQuery = query(
collectionGroup(firestore, 'members'),
where('uid', '==', callerUid)
);
const memberSnap = await getDocs(membersQuery);
for (const mDoc of memberSnap.docs) {
const mData = mDoc.data();
if (mData.status === 'active' && mData.role === 'admin') {
const clubId = mData.clubId || mDoc.ref.parent.parent?.id;
if (clubId && !clubMap.has(clubId)) {
try {
const clubSnap = await getDoc(doc(firestore, 'clubs', clubId));
if (clubSnap.exists()) {
const cd = clubSnap.data();
if (!cd.archivedAt) {
clubMap.set(clubId, {
id: clubSnap.id,
name: cd.name || 'Unnamed Club',
slug: cd.slug,
role: 'admin',
displayRank: mData.displayRank || 'Officer',
memberCount: typeof cd.memberCount === 'number' ? cd.memberCount : 1,
homeRegion: cd.homeRegion || 'Global',
logoUrl: cd.logoUrl || null,
});
}
}
} catch {
// Point read failure for club
}
}
}
}
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.error('[fetchManageableClubs Debug]:', error);
}
}
return Array.from(clubMap.values());
}
/**
* Isolated Context for Founder Portal Sub-Routes.
* Enforces O(1) point-read query gate (zero collection queries).
*/
export interface FounderClubContext {
club: Club;
currentUser: {
uid: string;
role: ClubRole;
displayRank?: string;
};
ownerProfile: {
displayName: string;
username?: string;
} | null;
permissions: UserPortalPermissions;
isDemoFixture: boolean;
}
/**
* Executes strictly O(1) point-reads to resolve club context for Founder sub-routes.
* Does NOT query group_rides, club_applications, or member rosters.
*/
export async function fetchFounderClubContext(
clubIdOrSlug: string,
callerUid?: string,
options?: { allowDemoPreview?: boolean }
): Promise<FounderClubContext> {
const normalized = (clubIdOrSlug || '').trim().toLowerCase();
// Explicit opt-in development preview fixture ONLY
if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') {
return {
club: FIXTURE_FOUNDER_CLUB,
currentUser: {
uid: callerUid || FIXTURE_FOUNDER_CLUB.ownerUid,
role: 'owner',
displayRank: 'President',
},
ownerProfile: {
displayName: FIXTURE_FOUNDER_CLUB.founderName || 'Marcus Vance',
username: 'marcus_vance',
},
permissions: getPortalPermissions('owner', true),
isDemoFixture: true,
};
}
if (!normalized) {
throw new Error('not-found');
}
let resolvedClubId = clubIdOrSlug;
// 1. Point read: resolve slug via club_handles
try {
const handleRef = doc(firestore, 'club_handles', normalized);
const handleSnap = await getDoc(handleRef);
if (handleSnap.exists()) {
resolvedClubId = handleSnap.data().clubId || clubIdOrSlug;
}
} catch {
// Fallback to direct ID
}
// 2. Point read: clubs/{clubId}
const clubRef = doc(firestore, 'clubs', resolvedClubId);
const clubSnap = await getDoc(clubRef);
if (!clubSnap.exists()) {
throw new Error('not-found');
}
const d = clubSnap.data();
if (d.archivedAt) {
throw new Error('not-found');
}
// 3. Point read: public_profiles/{ownerUid}
let ownerProfile: { displayName: string; username?: string } | null = null;
let founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder';
let founderHandle: string | undefined = undefined;
if (d.ownerUid) {
try {
const ownerSnap = await getDoc(doc(firestore, 'public_profiles', d.ownerUid));
if (ownerSnap.exists()) {
const pd = ownerSnap.data();
if (pd.displayName) {
founderName = callerUid === d.ownerUid ? 'You' : pd.displayName;
ownerProfile = {
displayName: pd.displayName,
username: pd.username,
};
}
if (pd.username) {
founderHandle = `@${pd.username}`;
}
}
} catch {
// Conservative fallback
}
}
// 4. Resolve caller role & permissions
let callerRole: ClubRole = 'member';
let callerRank = 'Member';
let isCallerAuth = Boolean(callerUid);
if (callerUid) {
if (callerUid === d.ownerUid) {
callerRole = 'owner';
callerRank = 'President';
} else {
try {
const myMemberSnap = await getDoc(doc(firestore, 'clubs', resolvedClubId, 'members', callerUid));
if (myMemberSnap.exists()) {
const mData = myMemberSnap.data();
if (mData.status === 'active') {
callerRole = (mData.role === 'admin' || mData.role === 'owner') ? mData.role : 'member';
callerRank = mData.displayRank || (callerRole === 'admin' ? 'Officer' : 'Member');
} else {
isCallerAuth = false;
}
} else {
isCallerAuth = false;
}
} catch {
isCallerAuth = false;
}
}
}
const club: Club = {
id: clubSnap.id,
name: d.name || 'Unnamed Club',
slug: d.slug || normalized,
description: d.description || '',
visibility: d.visibility || 'public',
admissionPolicy: d.admissionPolicy || 'request',
ownerUid: d.ownerUid || '',
memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1,
createdAt: d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : (d.createdAt || new Date().toISOString()),
updatedAt: d.updatedAt?.toDate?.() ? d.updatedAt.toDate().toISOString() : (d.updatedAt || new Date().toISOString()),
archivedAt: d.archivedAt?.toDate?.() ? d.archivedAt.toDate().toISOString() : (d.archivedAt || null),
logoUrl: d.logoUrl || null,
bannerUrl: d.bannerUrl || null,
homeRegion: d.homeRegion || 'Global',
motorcycleFocus: d.motorcycleFocus || 'General',
founderName,
founderHandle,
founderRank: 'Founder',
foundedYear: d.createdAt?.toDate?.() ? String(d.createdAt.toDate().getFullYear()) : '2024',
};
const permissions = getPortalPermissions(callerRole, isCallerAuth);
return {
club,
currentUser: {
uid: callerUid || '',
role: callerRole,
displayRank: callerRank,
},
ownerProfile,
permissions,
isDemoFixture: false,
};
}
export interface UpdateClubProfileInput {
name: string;
description: string;
homeRegion: string;
motorcycleFocus: string;
}
/**
* Whitelist update of club public profile metadata.
* Strictly verifies caller is owner or admin.
* Forbids mutating slug, visibility, admissionPolicy, ownerUid, memberCount, archivedAt.
*/
export async function updateClubProfile(
clubId: string,
callerUid: string,
input: UpdateClubProfileInput
): Promise<void> {
if (!callerUid) {
throw new Error('Authentication required.');
}
const name = (input.name ?? '').trim();
if (name.length < 3 || name.length > 50) {
throw new Error('Club name must be between 3 and 50 characters.');
}
const description = (input.description ?? '').trim();
if (description.length > 500) {
throw new Error('Club description must not exceed 500 characters.');
}
const homeRegion = (input.homeRegion ?? '').trim();
if (homeRegion.length > 100) {
throw new Error('Home region must not exceed 100 characters.');
}
const motorcycleFocus = (input.motorcycleFocus ?? '').trim();
if (motorcycleFocus.length > 100) {
throw new Error('Motorcycle focus must not exceed 100 characters.');
}
const clubRef = doc(firestore, 'clubs', clubId);
const clubSnap = await getDoc(clubRef);
if (!clubSnap.exists()) {
throw new Error('Club not found.');
}
const cd = clubSnap.data();
if (cd.archivedAt) {
throw new Error('Archived clubs cannot be modified.');
}
let isAuthorized = cd.ownerUid === callerUid;
if (!isAuthorized) {
try {
const memberSnap = await getDoc(doc(firestore, 'clubs', clubId, 'members', callerUid));
if (memberSnap.exists()) {
const m = memberSnap.data();
if (m.status === 'active' && (m.role === 'owner' || m.role === 'admin')) {
isAuthorized = true;
}
}
} catch {
// Unauthorized
}
}
if (!isAuthorized) {
throw new Error('Permission denied. Only club owners and administrators can edit club profile details.');
}
// Strict whitelist update
await updateDoc(clubRef, {
name,
description,
homeRegion,
motorcycleFocus,
updatedAt: serverTimestamp(),
});
}

View File

@ -0,0 +1,579 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
// Transpile TypeScript modules on the fly
function loadTsModule(relativePath) {
const fullPath = path.resolve(__dirname, relativePath);
const dir = path.dirname(fullPath);
const tsCode = fs.readFileSync(fullPath, 'utf8');
const jsCode = ts.transpileModule(tsCode, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const m = { exports: {} };
const customRequire = (specifier) => {
if (specifier === './firebase-client' || specifier.includes('firebase-client')) {
return { firestore: {}, firebaseAuth: { currentUser: null, authStateReady: async () => {} } };
}
if (specifier === 'firebase/firestore') {
return {
collection: () => ({}),
doc: (_db, ...parts) => ({ path: parts.join('/') }),
getDoc: async () => ({ exists: () => false, data: () => ({}) }),
getDocs: async () => ({ docs: [] }),
query: () => ({}),
where: () => ({}),
limit: () => ({}),
collectionGroup: () => ({}),
updateDoc: async () => {},
serverTimestamp: () => new Date(),
};
}
if (specifier.startsWith('.')) {
const resolved = path.resolve(dir, specifier);
if (fs.existsSync(resolved + '.ts')) return loadTsModule(path.relative(__dirname, resolved + '.ts'));
if (fs.existsSync(resolved + '.tsx')) return loadTsModule(path.relative(__dirname, resolved + '.tsx'));
if (fs.existsSync(resolved)) return require(resolved);
}
return require(specifier);
};
const fn = new Function('require', 'exports', 'module', '__filename', '__dirname', jsCode);
fn(customRequire, m.exports, m, fullPath, dir);
return m.exports;
}
console.log('--- STARTING FOUNDER PORTAL TEST SUITE ---');
// 1. Load domain logic & fixtures
const clubData = loadTsModule('../lib/club-data.ts');
const clubFixtures = loadTsModule('../lib/club-fixtures.ts');
const {
getPortalPermissions,
sanitizePortalError,
fetchClubData,
fetchManageableClubs,
fetchFounderClubContext,
updateClubProfile,
} = clubData;
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
// =========================================================================
// TEST 1: Role-Based Portal Access (Owner vs Admin vs Member vs Unauthenticated)
// =========================================================================
console.log('Testing: Role-based portal access...');
// Unauthenticated
const unauthPerms = getPortalPermissions('member', false);
assert.equal(unauthPerms.isAuthenticated, false);
assert.equal(unauthPerms.canManageSettings, false);
assert.equal(unauthPerms.canInviteMembers, false);
assert.equal(unauthPerms.canApproveApplications, false);
assert.equal(unauthPerms.canPlanRides, false);
assert.equal(unauthPerms.canTransferOwnership, false);
// Authenticated Member (Standard non-admin rider)
const memberPerms = getPortalPermissions('member', true);
assert.equal(memberPerms.isAuthenticated, true);
assert.equal(memberPerms.role, 'member');
assert.equal(memberPerms.canManageSettings, false, 'Member must not manage settings');
assert.equal(memberPerms.canInviteMembers, false, 'Member must not invite members');
assert.equal(memberPerms.canApproveApplications, false, 'Member must not approve applications');
assert.equal(memberPerms.canPlanRides, false, 'Member must not plan club rides');
assert.equal(memberPerms.canTransferOwnership, false, 'Member must not transfer ownership');
// Authenticated Admin
const adminPerms = getPortalPermissions('admin', true);
assert.equal(adminPerms.isAuthenticated, true);
assert.equal(adminPerms.role, 'admin');
assert.equal(adminPerms.canManageSettings, true, 'Admin can manage general settings');
assert.equal(adminPerms.canInviteMembers, true, 'Admin can invite members');
assert.equal(adminPerms.canApproveApplications, true, 'Admin can approve applications');
assert.equal(adminPerms.canPlanRides, true, 'Admin can plan club rides');
assert.equal(adminPerms.canTransferOwnership, false, 'Admin MUST NOT transfer ownership');
// Authenticated Owner
const ownerPerms = getPortalPermissions('owner', true);
assert.equal(ownerPerms.isAuthenticated, true);
assert.equal(ownerPerms.role, 'owner');
assert.equal(ownerPerms.canManageSettings, true);
assert.equal(ownerPerms.canInviteMembers, true);
assert.equal(ownerPerms.canApproveApplications, true);
assert.equal(ownerPerms.canPlanRides, true);
assert.equal(ownerPerms.canTransferOwnership, true, 'Only Owner can transfer ownership');
console.log(' PASS: Role-based permissions strictly enforced (Owner, Admin, Member, Unauth).');
// =========================================================================
// TEST 2: Club Display Rank != Administrative Role (Rank never grants access)
// =========================================================================
console.log('Testing: Club Rank does not escalate software permissions...');
// If a member has Display Rank 'President' but software role 'member':
const spoofedRankPerms = getPortalPermissions('member', true);
assert.equal(spoofedRankPerms.canApproveApplications, false);
assert.equal(spoofedRankPerms.canTransferOwnership, false);
console.log(' PASS: Club Rank is purely display/organizational; software permissions derive only from role.');
// =========================================================================
// TEST 3: Safe Error Sanitization (No raw backend exception leakage)
// =========================================================================
console.log('Testing: Safe error sanitization & leak prevention...');
const leaks = [
'FirebaseException: [cloud_firestore/permission-denied] Missing or insufficient permissions at /databases/(default)/documents/clubs/ms-atl-01',
'FAILED_PRECONDITION: The query requires an index.',
'Error: connect ECONNREFUSED 127.0.0.1:8080 at TCPConnectWrap.afterConnect [as oncomplete]',
'DocumentNotFoundException: document /databases/(default)/documents/clubs/unknown was not found',
'TypeError: Cannot read property uid of undefined at club_service.dart:142',
];
for (const rawLeak of leaks) {
const sanitized = sanitizePortalError(rawLeak);
assert(!sanitized.includes('FirebaseException'), `Leaked FirebaseException in: ${sanitized}`);
assert(!sanitized.includes('FAILED_PRECONDITION'), `Leaked FAILED_PRECONDITION in: ${sanitized}`);
assert(!sanitized.includes('/databases/'), `Leaked Firestore path in: ${sanitized}`);
assert(!sanitized.includes('127.0.0.1'), `Leaked network internals in: ${sanitized}`);
assert(!sanitized.includes('.dart:'), `Leaked stack trace in: ${sanitized}`);
assert(
sanitized === 'Club information is restricted or requires authentication.' ||
sanitized === "Club information couldn't be loaded. Check your network connection." ||
sanitized === 'Club information could not be found.' ||
sanitized === 'Something went wrong. Please try again.',
`Unexpected sanitized message: ${sanitized}`
);
}
console.log(' PASS: Zero raw backend exception or Firestore path leakage.');
// =========================================================================
// TEST 4: No Silent Production Demo Fallbacks
// =========================================================================
async function runAllTests() {
console.log('Testing: No silent production demo fallbacks...');
let threw = false;
try {
await fetchClubData('nonexistent-club-id');
} catch (err) {
threw = true;
assert.equal(err.message, 'Club information could not be found.');
}
assert.equal(threw, true, 'fetchClubData must throw not-found instead of returning demo club');
console.log(' PASS: Production runtime strictly refuses silent demo fallbacks.');
// =========================================================================
// TEST 5: ClubMember != GroupRideParticipant Invariant & Location Privacy
// =========================================================================
console.log('Testing: ClubMember != GroupRideParticipant & Location Privacy Invariant...');
// Read ClubRideCard source to verify exact verbatim notice text
const rideCardSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/club-ride-card.tsx'), 'utf8');
const verbatimNotice = 'Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.';
assert(rideCardSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in ClubRideCard');
// Verify that forbidden claims are NOT made in ClubRideCard
assert(!rideCardSrc.includes('end-to-end encryption'), 'ClubRideCard must not falsely claim end-to-end encryption');
assert(!rideCardSrc.includes('RTK'), 'ClubRideCard must not falsely claim RTK');
assert(!rideCardSrc.includes('mesh networking'), 'ClubRideCard must not falsely claim mesh networking');
console.log(' PASS: Verbatim live location privacy notice verified; no unverified claims present.');
// =========================================================================
// TEST 6: Club Mileage Privacy Behavior & Verified Badge Removal
// =========================================================================
console.log('Testing: Club Miles privacy behavior & badge hardening...');
const metricCardSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/metric-card.tsx'), 'utf8');
const mileageDisclaimer = 'Calculated from eligible completed Club Rides. Private rider mileage is not automatically included.';
assert(metricCardSrc.includes(mileageDisclaimer), 'Missing mandatory Club Miles privacy disclaimer');
assert(metricCardSrc.includes('Completed Club Rides only'), 'Missing "Completed Club Rides only" badge text');
assert(!metricCardSrc.includes('VERIFIED'), 'MetricCard must NOT contain misleading "VERIFIED" badge');
console.log(' PASS: Club Miles strict privacy semantics enforced (eligible completed rides only, no false VERIFIED badge).');
// =========================================================================
// TEST 7: Roles & Ranks Architecture Note & No Fabricated Numbers
// =========================================================================
console.log('Testing: Roles & Ranks separation architecture note...');
const rolesRanksSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/roles-and-ranks-summary.tsx'), 'utf8');
const verbatimRbacNote = 'Administrative roles determine software permissions. Club ranks are organizational titles and do not grant additional access.';
assert(rolesRanksSrc.includes(verbatimRbacNote), 'Missing verbatim RBAC architecture note');
assert(rolesRanksSrc.includes('ADMINISTRATIVE ROLES (Software Permissions)'), 'Missing software permissions section');
assert(rolesRanksSrc.includes('CLUB RANKS (ORGANIZATIONAL TITLES)'), 'Missing organizational titles section');
assert(!rolesRanksSrc.includes('106 Members'), 'RolesAndRanksSummary must not contain hardcoded 106 Members fabrication');
console.log(' PASS: Roles vs Ranks separation and verbatim architecture note verified (no fabricated counts).');
// =========================================================================
// TEST 8: Top Bar Dev-Gating
// =========================================================================
console.log('Testing: Top Bar role preview switcher dev-gating...');
const topBarSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-top-bar.tsx'), 'utf8');
assert(topBarSrc.includes("process.env.NODE_ENV === 'development'"), 'Top bar role preview must be dev-gated');
console.log(' PASS: Top Bar role preview switcher strictly dev-gated.');
// =========================================================================
// TEST 9: Dynamic /clubs/manage Discovery & Page Guards
// =========================================================================
console.log('Testing: /clubs/manage discovery route & access guards...');
const manageDiscoverySrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/manage/page.tsx'), 'utf8');
assert(!manageDiscoverySrc.includes("redirect('/clubs/motorsignal-atlanta/manage')"), 'Must NOT have static redirect to Atlanta');
assert(manageDiscoverySrc.includes('fetchManageableClubs'), 'Must discover manageable clubs via fetchManageableClubs');
assert(manageDiscoverySrc.includes('No Clubs to Manage'), 'Must render "No Clubs to Manage" empty state');
assert(manageDiscoverySrc.includes('Select Club to Manage'), 'Must render Club Selector for >1 clubs');
// FP-1.2: Administrative Role Terminology (Owner, Admin, Member - NEVER Officer)
assert(manageDiscoverySrc.includes("c.role === 'owner' ? 'Owner' : 'Admin'"), 'Manageable-Club selector must map admin role to "Admin", NOT "Officer"');
assert(!manageDiscoverySrc.includes("? 'Owner' : 'Officer'"), 'Manageable-Club selector must NEVER map admin role to "Officer"');
assert(!manageDiscoverySrc.includes("Owner or Officer"), 'Manageable-Club selector copy must say Owner or Admin, never Owner or Officer');
const managePageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/page.tsx'), 'utf8');
assert(managePageSrc.includes('Management Access Restricted'), 'Must guard against non-admin member access');
const clubsPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/page.tsx'), 'utf8');
assert(clubsPageSrc.includes('href="/clubs/manage"'), 'Missing link to /clubs/manage on /clubs page');
console.log(' PASS: /clubs/manage dynamic discovery, role terminology (Owner/Admin/Member), and access control verified.');
// =========================================================================
// TEST 10: Responsive Layout Breakpoints
// =========================================================================
console.log('Testing: Responsive layout classes across 1440, 1024, 768, and mobile...');
const shellSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-portal-shell.tsx'), 'utf8');
const sidebarSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-sidebar.tsx'), 'utf8');
// Desktop persistent sidebar: lg:pl-72, lg:translate-x-0, max-w-[1440px]
assert(shellSrc.includes('lg:pl-72'), 'Missing lg:pl-72 desktop sidebar offset');
assert(shellSrc.includes('max-w-[1440px]'), 'Missing max-w-[1440px] desktop canvas container');
assert(sidebarSrc.includes('w-72'), 'Missing w-72 persistent sidebar width');
// Mobile drawer: translate-x-full lg:translate-x-0, touch targets
assert(sidebarSrc.includes('-translate-x-full lg:translate-x-0'), 'Missing mobile drawer translation classes');
assert(sidebarSrc.includes('fixed left-0 top-0 h-full'), 'Missing fixed sidebar drawer layout');
console.log(' PASS: Responsive shell verified for 1440px desktop, 1024px tablet, and mobile off-canvas drawer.');
// =========================================================================
// TEST 11: FP-1.3 Founder Identity Contract
// =========================================================================
console.log('Testing: FP-1.3 Founder Identity Contract (/public_profiles point-read & conservative fallback)...');
const clubDataSrc = fs.readFileSync(path.resolve(__dirname, '../lib/club-data.ts'), 'utf8');
const typesSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/types.ts'), 'utf8');
// Verify ownerUid is documented as ownership identity only, not display name
assert(typesSrc.includes('Identifies ownership identity only'), 'Club.ownerUid must document ownership identity only');
assert(typesSrc.includes('Canonical profile resolution: /public_profiles/{ownerUid}'), 'Club must document profile resolution path');
assert(typesSrc.includes("Never persist a redundant 'founderName' field on /clubs/{clubId}"), 'Club must forbid redundant founderName in schema');
// Verify club-data.ts point-reads public_profiles/{ownerUid}
assert(clubDataSrc.includes("doc(firestore, 'public_profiles', d.ownerUid)"), 'fetchClubData must perform point read to public_profiles/{ownerUid}');
assert(clubDataSrc.includes("founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder'"), 'Must maintain conservative fallback if profile not resolved');
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)
// =========================================================================
console.log('Testing: FP-1.3 Canonical Club Ride Association (GroupRide.clubId == club.id)...');
// 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('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).');
// =========================================================================
// TEST 13: FP-1.3 Route Distance & Duration Storage Contract
// =========================================================================
console.log('Testing: FP-1.3 Route Distance & Duration Storage Contract...');
// Verify types.ts documents persisted straightLineDistance vs optional Valhalla road estimates
assert(typesSrc.includes('straightLineDistanceMeters: Geodesic Haversine distance between ordered stops (persisted)'), 'Must document straightLineDistanceMeters contract');
assert(typesSrc.includes('estimatedDistanceMeters & estimatedDurationSeconds: Optional Valhalla road estimates'), 'Must document optional road estimates contract');
// Verify club-ride-card renders '--' when metrics are unpopulated
assert(rideCardSrc.includes("ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'"), 'ClubRideCard must render -- when distance is unpopulated');
assert(rideCardSrc.includes("ride.durationString ?? '--'"), 'ClubRideCard must render -- when duration is unpopulated');
console.log(' PASS: Route distance & duration contract verified (persisted straight-line vs optional road estimates; fallback to --).');
// =========================================================================
// TEST 14 (Test A): FP-13 Profile Authorization Guard
// =========================================================================
console.log('Testing: FP-13 Test A - Profile Authorization Guard...');
// Owner and Admin allowed
const ownerPerms14 = getPortalPermissions('owner', true);
assert.equal(ownerPerms14.canManageSettings, true, 'Owner must have canManageSettings');
const adminPerms14 = getPortalPermissions('admin', true);
assert.equal(adminPerms14.canManageSettings, true, 'Admin must have canManageSettings');
// Member (non-admin) forbidden
const memberPerms14 = getPortalPermissions('member', true);
assert.equal(memberPerms14.canManageSettings, false, 'Member must NOT have canManageSettings');
// Unauthenticated forbidden
const unauthPerms14 = getPortalPermissions('member', false);
assert.equal(unauthPerms14.isAuthenticated, false, 'Unauth must not be authenticated');
assert.equal(unauthPerms14.canManageSettings, false, 'Unauth must NOT have canManageSettings');
// Verify page component inspects auth and permissions
const profilePageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/profile/page.tsx'), 'utf8');
assert(profilePageSrc.includes('!isDemoPreview && !currentUser'), 'Profile page must check authentication');
assert(profilePageSrc.includes('title="Authentication Required"'), 'Profile page must render Authentication Required error');
assert(profilePageSrc.includes("router.push('/login')"), 'Profile page must direct unauthenticated users to /login');
assert(profilePageSrc.includes('!isDemoPreview && !ctx.permissions.canManageSettings'), 'Profile page must check canManageSettings permission');
assert(profilePageSrc.includes('title="Management Access Restricted"'), 'Profile page must render Management Access Restricted error');
console.log(' PASS: Test A verified (Owner/Admin allowed; Member forbidden; Unauthenticated rejected with SafeErrorState -> /login).');
// =========================================================================
// TEST 15 (Test B): FP-13 Profile Query Gate (O(1) Point-Read Only)
// =========================================================================
console.log('Testing: FP-13 Test B - Profile Query Gate (O(1) Point-Read Only)...');
// Extract fetchFounderClubContext body from club-data.ts source
const fetchCtxMatch = clubDataSrc.match(/export async function fetchFounderClubContext[\s\S]*?^}/m);
assert(fetchCtxMatch, 'fetchFounderClubContext must exist in club-data.ts');
const fetchCtxBody = fetchCtxMatch[0];
// Forbid collection queries in profile context
assert(!fetchCtxBody.includes('group_rides'), 'fetchFounderClubContext must NOT query group_rides');
assert(!fetchCtxBody.includes('club_applications'), 'fetchFounderClubContext must NOT query club_applications');
assert(!fetchCtxBody.includes('collectionGroup'), 'fetchFounderClubContext must NOT perform collectionGroup queries');
assert(!fetchCtxBody.includes('getDocs('), 'fetchFounderClubContext must NOT call getDocs (strictly point-reads getDoc only)');
console.log(' PASS: Test B verified (Zero collection queries; zero group_rides; zero club_applications; strictly O(1) point-reads).');
// =========================================================================
// TEST 16 (Test C): FP-13 Profile Field Mutability & Whitelist Enforcement
// =========================================================================
console.log('Testing: FP-13 Test C - Profile Field Mutability & Whitelist Enforcement...');
// Test input validations via updateClubProfile
await assert.rejects(
async () => updateClubProfile('test-club', '', { name: 'Valid Name', description: '', homeRegion: '', motorcycleFocus: '' }),
/Authentication required/,
'Must require callerUid'
);
await assert.rejects(
async () => updateClubProfile('test-club', 'uid-1', { name: 'ab', description: '', homeRegion: '', motorcycleFocus: '' }),
/Club name must be between 3 and 50 characters/,
'Must reject name shorter than 3 chars'
);
await assert.rejects(
async () => updateClubProfile('test-club', 'uid-1', { name: 'A'.repeat(51), description: '', homeRegion: '', motorcycleFocus: '' }),
/Club name must be between 3 and 50 characters/,
'Must reject name longer than 50 chars'
);
await assert.rejects(
async () => updateClubProfile('test-club', 'uid-1', { name: 'Valid Club', description: 'A'.repeat(501), homeRegion: '', motorcycleFocus: '' }),
/Club description must not exceed 500 characters/,
'Must reject description longer than 500 chars'
);
await assert.rejects(
async () => updateClubProfile('test-club', 'uid-1', { name: 'Valid Club', description: '', homeRegion: 'A'.repeat(101), motorcycleFocus: '' }),
/Home region must not exceed 100 characters/,
'Must reject homeRegion longer than 100 chars'
);
await assert.rejects(
async () => updateClubProfile('test-club', 'uid-1', { name: 'Valid Club', description: '', homeRegion: '', motorcycleFocus: 'A'.repeat(101) }),
/Motorcycle focus must not exceed 100 characters/,
'Must reject motorcycleFocus longer than 100 chars'
);
// Verify updateClubProfile implementation in club-data.ts strictly writes whitelisted fields
const updateMatch = clubDataSrc.match(/export async function updateClubProfile[\s\S]*?^}/m);
assert(updateMatch, 'updateClubProfile must exist in club-data.ts');
const updateBody = updateMatch[0];
assert(updateBody.includes('updateDoc(clubRef, {'), 'updateClubProfile must call updateDoc');
assert(!updateBody.includes('slug:'), 'updateClubProfile MUST NOT write slug');
assert(!updateBody.includes('visibility:'), 'updateClubProfile MUST NOT write visibility');
assert(!updateBody.includes('admissionPolicy:'), 'updateClubProfile MUST NOT write admissionPolicy');
assert(!updateBody.includes('ownerUid:'), 'updateClubProfile MUST NOT write ownerUid');
assert(!updateBody.includes('memberCount:'), 'updateClubProfile MUST NOT write memberCount');
assert(!updateBody.includes('archivedAt:'), 'updateClubProfile MUST NOT write archivedAt');
console.log(' PASS: Test C verified (Strict input length bounds, whitelisted update, forbidden fields rejected).');
// =========================================================================
// TEST 17 (Test D): FP-13 Development Preview Fixture Safety
// =========================================================================
console.log('Testing: FP-13 Test D - Development Preview Fixture Safety...');
// In development with allowDemoPreview: true -> returns fixture
const prevEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const demoCtx = await fetchFounderClubContext('any-id', undefined, { allowDemoPreview: true });
assert.equal(demoCtx.isDemoFixture, true, 'Must return isDemoFixture: true when opt-in in development');
assert.equal(demoCtx.club.name, FIXTURE_FOUNDER_CLUB.name);
// In production with allowDemoPreview: true -> fixture MUST NOT be returned
process.env.NODE_ENV = 'production';
let prodThrew = false;
try {
await fetchFounderClubContext('non-existent-club-id', undefined, { allowDemoPreview: true });
} catch {
prodThrew = true;
}
assert.equal(prodThrew, true, 'Fixture MUST NEVER be returned in production, even if allowDemoPreview is set');
process.env.NODE_ENV = prevEnv;
console.log(' PASS: Test D verified (Fixture returned strictly in development with explicit allowDemoPreview opt-in; never in production).');
// =========================================================================
// TEST 18 (Test E): FP-13 Shared Sub-Routing & Active Navigation State
// =========================================================================
console.log('Testing: FP-13 Test E - Shared Sub-Routing & Active Navigation State...');
// Verify route links exist in FounderSidebar
assert(sidebarSrc.includes('getRouteHref'), 'FounderSidebar must include getRouteHref');
assert(sidebarSrc.includes('/manage/profile'), 'FounderSidebar must route to /manage/profile');
assert(sidebarSrc.includes('/manage'), 'FounderSidebar must route to /manage');
// Verify exact active styling for profile
assert(sidebarSrc.includes('bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'), 'Profile item must have active highlight border and background');
// Verify profile page passes activeNav="profile"
assert(profilePageSrc.includes('activeNav="profile"'), 'Profile page must pass activeNav="profile" to FounderPortalShell');
assert(profilePageSrc.includes('<FounderPortalShell'), 'Profile page must reuse FounderPortalShell (not duplicate shell)');
console.log(' PASS: Test E verified (Shared sub-routing, activeNav="profile", shell reuse without duplication).');
// =========================================================================
// TEST 19 (Test F): FP-13 Privacy Boundary Contract
// =========================================================================
console.log('Testing: FP-13 Test F - Privacy Boundary Contract...');
const editorSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-profile-editor.tsx'), 'utf8');
// Verify verbatim privacy contract statement
assert(editorSrc.includes('Public Club Profile is organizational identity only. Member telemetry, GPS tracks, personal ride logs, and private account data are not included.'), 'Editor must contain canonical Privacy Contract statement');
assert(editorSrc.includes('PRIVACY CONTRACT'), 'Editor must have PRIVACY CONTRACT section');
// Verify zero telemetry leakage in editor
assert(!editorSrc.includes('leanAngle'), 'Profile editor must not reference telemetry leanAngle');
assert(!editorSrc.includes('speedMph'), 'Profile editor must not reference telemetry speedMph');
assert(!editorSrc.includes('gForce'), 'Profile editor must not reference telemetry gForce');
console.log(' PASS: Test F verified (Privacy contract displayed, personal telemetry/GPS strictly isolated).');
// =========================================================================
// TEST 20 (Test G): FP-13 Public Directory Preview Contract
// =========================================================================
console.log('Testing: FP-13 Test G - Public Directory Preview Contract...');
// Verify Directory Preview exists and renders identity
assert(editorSrc.includes('DIRECTORY PREVIEW'), 'Editor must include DIRECTORY PREVIEW');
assert(editorSrc.includes('LIVE SIMULATION'), 'Editor must display LIVE SIMULATION badge');
assert(editorSrc.includes('@{club.slug}'), 'Directory preview must render club handle');
assert(editorSrc.includes('initials'), 'Directory preview must render emblem initials');
// Verify ZERO aggregate metrics in directory preview
const previewMatch = editorSrc.match(/CARD A: PUBLIC DIRECTORY PREVIEW[\s\S]*?CARD B: ACCESS/);
assert(previewMatch, 'Card A Directory Preview section must be identifiable');
const previewBody = previewMatch[0];
assert(!previewBody.includes('club.memberCount'), 'Directory Preview MUST NOT render member count');
assert(!previewBody.includes('MEMBERS'), 'Directory Preview MUST NOT render MEMBERS metric');
assert(!previewBody.includes('UPCOMING RIDES'), 'Directory Preview MUST NOT render UPCOMING RIDES metric');
assert(!previewBody.includes('TOTAL MILES'), 'Directory Preview MUST NOT render TOTAL MILES metric');
console.log(' PASS: Test G verified (Directory preview renders organizational identity only; zero aggregate metrics).');
// =========================================================================
// TEST 21: FP-13.1 Club Profile Hardening Suite (Tests A through J)
// =========================================================================
console.log('Testing: FP-13.1 Hardening Suite (Tests A - J)...');
// A. FounderPortalShell does not contain STATUS: NOMINAL or global metric footer
assert(!shellSrc.includes('STATUS: NOMINAL'), 'FounderPortalShell must NOT contain STATUS: NOMINAL');
assert(!shellSrc.includes('MEMBERS'), 'FounderPortalShell must NOT contain global MEMBERS metric');
assert(!shellSrc.includes('UPCOMING RIDES'), 'FounderPortalShell must NOT contain global UPCOMING RIDES metric');
assert(!shellSrc.includes('ACCESSIBLE ROUTES'), 'FounderPortalShell must NOT contain global ACCESSIBLE ROUTES metric');
assert(!shellSrc.includes('<footer'), 'FounderPortalShell must NOT contain global operational status footer');
// B. Profile contains no unsupported verified badge
assert(!editorSrc.includes('title="Verified Syndicate"'), 'Profile must NOT contain Verified Syndicate title');
assert(!editorSrc.includes('verified'), 'Profile must NOT contain verified checkmark badge');
// C. Profile contains no FP-13 / MODULE implementation terminology
assert(!editorSrc.includes('FP-13'), 'Profile must NOT contain FP-13 terminology');
assert(!editorSrc.includes('FP-13.1'), 'Profile must NOT contain FP-13.1 terminology');
assert(!editorSrc.includes('MODULE'), 'Profile must NOT contain MODULE terminology');
assert(!editorSrc.includes('STAGE'), 'Profile must NOT contain STAGE terminology');
// D. Profile contains no internal Club document/registry ID
assert(!editorSrc.includes('REGISTRY REF'), 'Profile must NOT contain REGISTRY REF');
assert(!editorSrc.includes('club.id.slice'), 'Profile must NOT expose truncated Club document ID');
// E. Profile contains no unsupported media format/storage labels
assert(!editorSrc.includes('VECTOR_BOUND'), 'Profile must NOT contain VECTOR_BOUND');
assert(!editorSrc.includes('CANVAS // TELEMETRY DUSK'), 'Profile must NOT contain CANVAS // TELEMETRY DUSK');
assert(!editorSrc.includes('512x512 PNG'), 'Profile must NOT contain 512x512 PNG requirement');
assert(!editorSrc.includes('16:5 RATIO'), 'Profile must NOT contain 16:5 RATIO label');
assert(!editorSrc.includes('lh3.googleusercontent.com'), 'Profile must NOT contain fabricated stock car photo');
// F. Directory Preview remains identity-only
assert(!previewBody.includes('club.memberCount'), 'Directory Preview MUST NOT render member count');
assert(!previewBody.includes('MEMBERS'), 'Directory Preview MUST NOT render MEMBERS metric');
assert(!previewBody.includes('UPCOMING RIDES'), 'Directory Preview MUST NOT render UPCOMING RIDES metric');
assert(!previewBody.includes('TOTAL MILES'), 'Directory Preview MUST NOT render TOTAL MILES metric');
assert(!previewBody.includes('verified'), 'Directory Preview MUST NOT render verified badge');
// G. visibility/admission remain non-editable
assert(!updateBody.includes('visibility:'), 'updateClubProfile MUST NOT write visibility');
assert(!updateBody.includes('admissionPolicy:'), 'updateClubProfile MUST NOT write admissionPolicy');
assert(!editorSrc.includes('name="visibility"'), 'Profile editor must NOT have visibility input');
assert(!editorSrc.includes('name="admissionPolicy"'), 'Profile editor must NOT have admissionPolicy input');
// H. O(1) point-read contract unchanged
assert(!fetchCtxBody.includes('group_rides'), 'fetchFounderClubContext must NOT query group_rides');
assert(!fetchCtxBody.includes('club_applications'), 'fetchFounderClubContext must NOT query club_applications');
assert(!fetchCtxBody.includes('collectionGroup'), 'fetchFounderClubContext must NOT perform collectionGroup queries');
assert(!fetchCtxBody.includes('getDocs('), 'fetchFounderClubContext must NOT call getDocs');
// I. profile mutation whitelist unchanged
assert(updateBody.includes('updateDoc(clubRef, {'), 'updateClubProfile must call updateDoc');
assert(!updateBody.includes('slug:'), 'updateClubProfile MUST NOT write slug');
assert(!updateBody.includes('ownerUid:'), 'updateClubProfile MUST NOT write ownerUid');
assert(!updateBody.includes('memberCount:'), 'updateClubProfile MUST NOT write memberCount');
assert(!updateBody.includes('archivedAt:'), 'updateClubProfile MUST NOT write archivedAt');
// J. production fixture isolation unchanged
assert.equal(demoCtx.isDemoFixture, true, 'Must return isDemoFixture: true in development with opt-in');
assert.equal(prodThrew, true, 'Fixture MUST NEVER be returned in production');
console.log(' PASS: FP-13.1 Tests A through J verified (Footer removed, verified badge removed, stage terms removed, internal ID removed, media labels removed, preview identity-only, non-editable visibility/admission, O(1) reads, whitelist mutation, fixture safety).');
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (21/21) ---');
}
runAllTests().catch((err) => {
console.error('Test Suite Failure:', err);
process.exit(1);
});