feat(clubs): add public syndicates directory, club discovery, and founder management portal

This commit is contained in:
Developer 2026-09-18 18:35:21 -04:00
parent fa2fccd137
commit 9418aa2ac8
26 changed files with 8207 additions and 0 deletions

View File

@ -0,0 +1,139 @@
'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 { FounderApplications } from '../../../../../components/founder-portal/founder-applications';
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 ClubFounderApplicationsPage({ 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: Owner and Admin can access Applications; Members cannot
if (!isDemoPreview && (!ctx.permissions.canApproveApplications && !ctx.permissions.canManageSettings)) {
setUnauthorized(true);
setLoading(false);
return;
}
setContext(ctx);
} catch (err) {
setError(sanitizePortalError(err, "Club applications 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 applications."
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 Applications."
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="Applications Unavailable"
message={error ?? "Club applications couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
);
}
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="applications"
pendingApplicationsCount={4}
upcomingRidesCount={2}
>
<FounderApplications
club={context.club}
permissions={context.permissions}
callerUid={context.currentUser.uid}
isDemoFixture={context.isDemoFixture}
/>
</FounderPortalShell>
);
}

View File

@ -0,0 +1,151 @@
'use client';
import React, { useEffect, useState, use } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Club,
ClubMetrics,
ClubUpcomingRide,
ClubMember,
ClubActivityItem,
ClubRole,
UserPortalPermissions,
} from '../../../../components/founder-portal/types';
import {
fetchClubData,
getPortalPermissions,
sanitizePortalError,
} from '../../../../lib/club-data';
import { FounderPortalShell } from '../../../../components/founder-portal/founder-portal-shell';
import { FounderOverview } from '../../../../components/founder-portal/founder-overview';
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 ClubFounderPortalPage({ params }: PageProps) {
const { clubId } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const [club, setClub] = useState<Club | null>(null);
const [metrics, setMetrics] = useState<ClubMetrics | null>(null);
const [upcomingRides, setUpcomingRides] = useState<ClubUpcomingRide[]>([]);
const [pendingApplications, setPendingApplications] = useState<ClubMember[]>([]);
const [activities, setActivities] = useState<ClubActivityItem[]>([]);
const [userRole, setUserRole] = useState<ClubRole>('owner');
const [userRank, setUserRank] = useState('President');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeNav, setActiveNav] = useState('overview');
async function loadData() {
setLoading(true);
setError(null);
try {
await firebaseAuth.authStateReady();
const currentUser = firebaseAuth.currentUser;
const isDemoPreview = searchParams?.get('preview') === 'demo';
const data = await fetchClubData(clubId, currentUser?.uid, {
allowDemoPreview: isDemoPreview,
});
setClub(data.club);
setMetrics(data.metrics);
setUpcomingRides(data.upcomingRides);
setPendingApplications(data.pendingApplications);
setActivities(data.activities);
setUserRole(data.userRole);
setUserRank(data.userRank);
} catch (err) {
setError(sanitizePortalError(err, "Club information couldn't be loaded."));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData();
}, [clubId]);
const permissions: UserPortalPermissions = getPortalPermissions(userRole, true);
if (loading) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6 flex items-center justify-center">
<div className="w-full max-w-5xl">
<OverviewSkeleton />
</div>
</div>
);
}
// Unauthenticated Guard
const isDemo = searchParams?.get('preview') === 'demo';
if (!firebaseAuth.currentUser && !isDemo) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-4 sm:p-6">
<SafeErrorState
title="Authentication Required"
message="Sign in with your rider account to access this club's management portal."
actionLabel="Sign In"
onAction={() => router.push(`/login?returnUrl=/clubs/${clubId}/manage`)}
/>
</div>
);
}
if (error || !club || !metrics) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-4 sm:p-6">
<SafeErrorState
title="Club Unavailable"
message={error ?? "Club information couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
);
}
// Member Access Guard: Non-admin members are forbidden from administrative portal
const isDev = process.env.NODE_ENV === 'development';
if (!isDev && userRole !== 'owner' && userRole !== 'admin') {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-4 sm:p-6">
<SafeErrorState
title="Management Access Restricted"
message="You must be an Owner or Admin of this Club to access the Founder Portal."
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
/>
</div>
);
}
return (
<FounderPortalShell
club={club}
permissions={permissions}
activeNav={activeNav}
onNavSelect={(nav: string) => setActiveNav(nav)}
onSwitchRole={(newRole: ClubRole) => setUserRole(newRole)}
pendingApplicationsCount={pendingApplications.length}
upcomingRidesCount={upcomingRides.length}
>
<FounderOverview
club={club}
metrics={metrics}
upcomingRides={upcomingRides}
pendingApplications={pendingApplications}
activities={activities}
permissions={permissions}
onRefresh={loadData}
/>
</FounderPortalShell>
);
}

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,158 @@
'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 { FounderSettingsEditor } from '../../../../../components/founder-portal/founder-settings-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 ClubFounderSettingsPage({ 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: Owner and Admin can access Settings; Members cannot
if (!isDemoPreview && !ctx.permissions.canManageSettings) {
setUnauthorized(true);
setLoading(false);
return;
}
setContext(ctx);
} catch (err) {
setError(sanitizePortalError(err, "Club settings 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 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 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="Settings Unavailable"
message={error ?? "Club settings couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
);
}
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="settings"
pendingApplicationsCount={4}
upcomingRidesCount={2}
>
<FounderSettingsEditor
club={context.club}
permissions={context.permissions}
callerUid={context.currentUser.uid}
isDemoFixture={context.isDemoFixture}
onClubUpdated={(updated) => {
setContext((prev) => {
if (!prev) return null;
const isFormerOwner = updated.ownerUid && updated.ownerUid !== prev.club.ownerUid;
return {
...prev,
club: {
...prev.club,
...updated,
},
currentUser: isFormerOwner
? { ...prev.currentUser, role: 'admin', displayRank: 'Admin' }
: prev.currentUser,
permissions: isFormerOwner
? { ...prev.permissions, role: 'admin', canTransferOwnership: false }
: prev.permissions,
};
});
}}
/>
</FounderPortalShell>
);
}

View File

@ -0,0 +1,287 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { onAuthStateChanged, User } from 'firebase/auth';
import { firebaseAuth } from '../../../lib/firebase-client';
import { fetchManageableClubs, ManageableClubSummary } from '../../../lib/club-data';
import { OverviewSkeleton } from '../../../components/founder-portal/skeleton-state';
import { SafeErrorState } from '../../../components/founder-portal/safe-error-state';
export default function ClubManageDiscoveryPage() {
const router = useRouter();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [loading, setLoading] = useState(true);
const [clubs, setClubs] = useState<ManageableClubSummary[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
setCurrentUser(user);
setAuthChecked(true);
});
return () => unsubscribe();
}, []);
async function loadClubs(uid: string) {
setLoading(true);
setError(null);
try {
const results = await fetchManageableClubs(uid);
setClubs(results);
if (results.length === 1) {
// Automatically redirect to the single manageable club
const target = results[0].slug || results[0].id;
router.replace(`/clubs/${target}/manage`);
}
} catch {
setError("Unable to discover manageable clubs. Please check your network connection.");
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!authChecked) return;
if (currentUser) {
void loadClubs(currentUser.uid);
} else {
setLoading(false);
}
}, [authChecked, currentUser]);
// Loading state (auth checking or discovering clubs)
if (!authChecked || loading) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg pt-28 pb-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton />
</div>
</div>
);
}
// Unauthenticated state: prompt sign-in
if (!currentUser) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-4 sm:p-6 pt-28 pb-20">
<div className="glass-card rounded-2xl border border-brand-border p-8 sm:p-10 max-w-lg w-full text-center shadow-2xl space-y-6 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-brand-cyan/80 via-blue-500/40 to-transparent"></div>
<div className="w-16 h-16 rounded-2xl bg-surface-container border border-brand-cyan/40 text-brand-cyan shadow-[0_0_20px_rgba(0,242,254,0.2)] flex items-center justify-center mx-auto">
<span className="material-symbols-outlined text-[32px]">lock</span>
</div>
<div>
<div className="inline-flex items-center gap-1.5 font-mono text-xs tracking-wider text-brand-cyan font-semibold mb-1.5">
<span className="w-2 h-2 rounded-full bg-brand-cyan animate-pulse"></span>
<span>Rider Authentication Required</span>
</div>
<h1 className="font-headline text-3xl font-extrabold text-white tracking-tight">
Sign In to Access Portal
</h1>
<p className="font-body text-xs sm:text-sm text-slate-400 mt-2 leading-relaxed">
Founder Portal requires an active MotorSignal rider account with Club Owner or Admin administration permissions.
</p>
</div>
<div className="flex flex-col gap-3 pt-2">
<Link
href="/login?returnUrl=/clubs/manage"
className="w-full py-3 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-xs sm:text-sm font-bold hover:brightness-110 shadow-[0_0_20px_rgba(0,242,254,0.35)] transition-all flex items-center justify-center gap-2"
>
<span className="material-symbols-outlined text-[18px]">login</span>
<span>Sign In with Rider Account</span>
</Link>
<Link
href="/register"
className="w-full py-3 rounded-xl bg-surface-container border border-outline-variant/60 text-slate-200 hover:text-white hover:bg-surface-container-high transition-colors font-body text-xs sm:text-sm font-medium flex items-center justify-center"
>
<span>Create New Account</span>
</Link>
<Link
href="/clubs"
className="font-mono text-xs text-slate-400 hover:text-brand-cyan transition-colors mt-2"
>
Browse Public Motorcycle Clubs →
</Link>
</div>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-4 sm:p-6 pt-28 pb-20">
<SafeErrorState
title="Discovery Error"
message={error}
onRetry={() => loadClubs(currentUser.uid)}
/>
</div>
);
}
// Single club redirecting transition
if (clubs.length === 1) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-4 sm:p-6 pt-28 pb-20">
<div className="text-center space-y-4 font-body glass-card rounded-2xl border border-brand-border p-8 max-w-sm w-full">
<div className="w-12 h-12 border-3 border-brand-cyan border-t-transparent rounded-full animate-spin mx-auto shadow-[0_0_16px_rgba(0,242,254,0.3)]" />
<p className="font-mono text-xs text-brand-cyan font-medium">
Redirecting to {clubs[0].name}...
</p>
</div>
</div>
);
}
// 0 clubs: No clubs to manage
if (clubs.length === 0) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-4 sm:p-6 pt-28 pb-20">
<div className="glass-card rounded-2xl border border-brand-border p-8 sm:p-10 max-w-lg w-full text-center shadow-2xl space-y-6 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-brand-cyan/80 via-blue-500/40 to-transparent"></div>
<div className="w-16 h-16 rounded-2xl bg-surface-container border border-outline-variant/60 text-slate-400 flex items-center justify-center mx-auto">
<span className="material-symbols-outlined text-[32px]">hub</span>
</div>
<div>
<span className="font-mono text-xs tracking-wider text-slate-400 font-semibold">
Management Roster // 0 Clubs
</span>
<h1 className="font-headline text-3xl font-extrabold text-white tracking-tight mt-1.5">
No Clubs to Manage
</h1>
<p className="font-body text-xs sm:text-sm text-slate-400 mt-2 leading-relaxed max-w-md mx-auto">
Your rider account doesn&apos;t currently have Owner or Admin permissions for any registered clubs. To manage a club, create a new club or request admin privileges from your club owner.
</p>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 pt-2">
<Link
href="/clubs#register-club"
className="w-full sm:w-auto px-6 py-3 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-xs sm:text-sm font-bold hover:brightness-110 shadow-[0_0_16px_rgba(0,242,254,0.3)] transition-all flex items-center justify-center gap-2"
>
<span className="material-symbols-outlined text-[18px]">add_circle</span>
<span>Register Your Club</span>
</Link>
<Link
href="/clubs"
className="w-full sm:w-auto px-6 py-3 rounded-xl bg-surface-container border border-outline-variant/60 text-slate-200 font-body text-xs sm:text-sm font-medium hover:bg-surface-container-high transition-colors flex items-center justify-center gap-2"
>
<span className="material-symbols-outlined text-[18px]">explore</span>
<span>Browse Public Clubs</span>
</Link>
</div>
</div>
</div>
);
}
// >1 clubs: Clean Club Selector
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg text-slate-200 pt-28 pb-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-6 border-b border-brand-border">
<div>
<div className="inline-flex items-center gap-1.5 font-mono text-xs tracking-wider text-brand-cyan font-semibold mb-1">
<span className="w-2 h-2 rounded-full bg-brand-cyan animate-pulse"></span>
<span>Founder Portal Cockpit</span>
</div>
<h1 className="font-headline text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
Select Club to Manage
</h1>
<p className="font-body text-sm sm:text-base text-slate-400 mt-1">
Choose an active motorcycle club from your authorized administrative rosters.
</p>
</div>
<Link
href="/clubs#register-club"
className="self-start sm:self-auto px-4 py-2.5 rounded-xl bg-surface-container border border-outline-variant/60 text-slate-200 hover:text-white hover:border-brand-cyan/50 font-body text-xs sm:text-sm font-medium transition-all shadow-sm flex items-center gap-2"
>
<span className="material-symbols-outlined text-[18px] text-brand-cyan">add</span>
<span>Register New Club</span>
</Link>
</div>
{/* Club Cards Grid */}
<div className="grid grid-cols-1 gap-4">
{clubs.map((c) => (
<Link
key={c.id}
href={`/clubs/${c.slug || c.id}/manage`}
className="group rounded-2xl glass-card border border-brand-border hover:border-brand-cyan/60 p-6 transition-all shadow-lg hover:shadow-[0_0_24px_rgba(0,242,254,0.15)] flex flex-col sm:flex-row sm:items-center sm:justify-between gap-5 relative overflow-hidden"
>
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan via-blue-500/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="flex items-center gap-4 sm:gap-5 min-w-0">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-brand-borderLight to-slate-900 border border-brand-cyan/30 flex items-center justify-center text-brand-cyan shrink-0 group-hover:border-brand-cyan group-hover:shadow-[0_0_16px_rgba(0,242,254,0.35)] transition-all">
<span className="material-symbols-outlined text-[28px]">sports_motorsports</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2.5">
<h2 className="font-headline text-lg sm:text-xl font-bold text-white group-hover:text-brand-cyan transition-colors truncate">
{c.name}
</h2>
<span
className={`px-2.5 py-0.5 rounded-full font-mono text-xs font-semibold tracking-normal ${
c.role === 'owner'
? 'bg-primary-container/15 text-primary-container border border-primary-container/30'
: 'bg-secondary-container/15 text-secondary-fixed border border-secondary-container/30'
}`}
>
{c.role === 'owner' ? 'Owner' : 'Admin'}
</span>
</div>
<div className="flex flex-wrap items-center gap-2.5 font-mono text-xs text-slate-400 mt-1.5">
{c.slug && <span className="text-slate-300">/{c.slug}</span>}
<span>•</span>
<span>{c.memberCount} {c.memberCount === 1 ? 'member' : 'members'}</span>
{c.homeRegion && (
<>
<span>•</span>
<span>{c.homeRegion}</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center font-mono text-xs text-brand-cyan group-hover:translate-x-1 transition-transform shrink-0 font-medium">
<span>Manage Cockpit</span>
<span className="material-symbols-outlined text-[20px]">
arrow_forward
</span>
</div>
</Link>
))}
</div>
{/* Footer info */}
<div className="p-4 rounded-xl glass-card border border-outline-variant/40 text-slate-400 font-body text-xs flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-brand-cyan">shield</span>
<span>Only clubs where you are an active Owner or Admin appear here.</span>
</div>
<Link href="/clubs" className="font-mono text-xs text-brand-cyan hover:underline">
All Public Clubs →
</Link>
</div>
</div>
</div>
);
}

601
website/app/clubs/page.tsx Normal file
View File

@ -0,0 +1,601 @@
'use client';
import React from 'react';
import Link from 'next/link';
export default function ClubsPage() {
return (
<div className="flagship-page text-on-surface bg-surface min-h-screen">
<div className="flex flex-col w-full">
{/* Top Command Matrix Hero */}
<section className="relative w-full py-space-4xl overflow-hidden bg-surface-container-lowest">
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-[900px] h-[450px] bg-primary-container/10 blur-[130px] rounded-full pointer-events-none"></div>
<div className="absolute top-1/3 right-10 w-[420px] h-[420px] bg-secondary-container/5 blur-[120px] rounded-full pointer-events-none"></div>
<div className="relative max-w-container-max mx-auto px-gutter-desktop">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-space-2xl items-center">
<div className="lg:col-span-7 flex flex-col gap-space-lg">
<div className="inline-flex items-center gap-space-xs px-space-sm py-1.5 rounded-full bg-surface-container w-fit shadow-md">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-container opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary-fixed-dim"></span>
</span>
<span className="font-label-caps text-label-caps text-primary-fixed tracking-normal">Motorcycle Clubs, Fleets &amp; Track Academies</span>
</div>
<h1 className="font-display-hero text-display-hero text-on-surface tracking-tight">
Command your club fleet with <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-container via-primary-fixed-dim to-secondary-fixed">military-grade telemetry</span>.
</h1>
<p className="font-body-lg text-body-lg text-on-surface-variant max-w-xl">
Empower road captains, track day organizers, and racing teams with central member rosters, telemetry leaderboards, safety beacon monitoring, and fleet maintenance.
</p>
<div className="flex flex-wrap items-center gap-space-md pt-space-xs">
<a className="inline-flex items-center justify-center gap-space-xs px-space-xl py-3.5 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm shadow-xl hover:shadow-[0_0_30px_rgba(0,242,254,0.45)] transition-all" href="#register-club">
<span className="material-symbols-outlined text-[20px]">hub</span>
<span>Register Your Club</span>
</a>
<Link className="inline-flex items-center justify-center gap-space-xs px-space-xl py-3.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-headline-sm shadow-md transition-all border border-outline-variant/60 hover:border-primary/40" href="/clubs/manage">
<span className="material-symbols-outlined text-[20px] text-secondary">dashboard</span>
<span>Founder Portal</span>
</Link>
<a className="inline-flex items-center justify-center gap-space-xs px-space-xl py-3.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-headline-sm shadow-md transition-all" href="#directory">
<span className="material-symbols-outlined text-[20px]">explore</span>
<span>Explore Verified Clubs</span>
</a>
</div>
<div className="grid grid-cols-3 gap-space-md pt-space-lg max-w-lg">
<div className="p-space-sm bg-surface-container-low rounded-lg shadow-sm">
<span className="font-code-mono text-code-mono text-outline block">Sync Rate</span>
<span className="font-telemetry-metric-md text-telemetry-metric-md text-primary-container block">10Hz</span>
<span className="font-body-sm text-body-sm text-on-surface-variant">Real-time CAN-bus</span>
</div>
<div className="p-space-sm bg-surface-container-low rounded-lg shadow-sm">
<span className="font-code-mono text-code-mono text-outline block">Active Fleets</span>
<span className="font-telemetry-metric-md text-telemetry-metric-md text-secondary-fixed block">1,840+</span>
<span className="font-body-sm text-body-sm text-on-surface-variant">Global syndicates</span>
</div>
<div className="p-space-sm bg-surface-container-low rounded-lg shadow-sm">
<span className="font-code-mono text-code-mono text-outline block">Safety Beacon</span>
<span className="font-telemetry-metric-md text-telemetry-metric-md text-tertiary-fixed-dim block">&lt;400ms</span>
<span className="font-body-sm text-body-sm text-on-surface-variant">Crash dispatch latency</span>
</div>
</div>
</div>
<div className="lg:col-span-5 relative">
<div className="relative bg-surface-container-low rounded-xl p-space-lg shadow-2xl overflow-hidden">
<div className="flex items-center justify-between pb-space-sm">
<div className="flex items-center gap-space-xs">
<span className="h-2.5 w-2.5 rounded-full bg-secondary-fixed"></span>
<span className="font-label-caps text-label-caps text-secondary-fixed">Live Roster Stream</span>
</div>
<span className="font-code-mono text-code-mono text-outline">Net: 48 Node Mesh</span>
</div>
<div className="space-y-space-xs py-space-xs">
<div className="p-space-sm rounded-lg bg-surface-container flex items-center justify-between">
<div className="flex items-center gap-space-sm">
<div className="w-8 h-8 rounded-full bg-surface-container-highest flex items-center justify-center font-code-mono text-code-mono text-primary-fixed">01</div>
<div>
<div className="font-headline-sm text-headline-sm text-on-surface">Cap. Marcus Vance</div>
<div className="font-code-mono text-code-mono text-on-surface-variant">Yamaha YZF-R1M • Leader</div>
</div>
</div>
<div className="text-right">
<div className="font-telemetry-metric-md text-telemetry-metric-md text-secondary-fixed">54.2°</div>
<div className="font-code-mono text-code-mono text-outline">Apex Lean</div>
</div>
</div>
<div className="p-space-sm rounded-lg bg-surface-container flex items-center justify-between">
<div className="flex items-center gap-space-sm">
<div className="w-8 h-8 rounded-full bg-surface-container-highest flex items-center justify-center font-code-mono text-code-mono text-primary-fixed">02</div>
<div>
<div className="font-headline-sm text-headline-sm text-on-surface">Elena Rostova</div>
<div className="font-code-mono text-code-mono text-on-surface-variant">Ducati Panigale V4 S</div>
</div>
</div>
<div className="text-right">
<div className="font-telemetry-metric-md text-telemetry-metric-md text-primary-container">1:41.88</div>
<div className="font-code-mono text-code-mono text-outline">Sector 3 Split</div>
</div>
</div>
<div className="p-space-sm rounded-lg bg-surface-container flex items-center justify-between">
<div className="flex items-center gap-space-sm">
<div className="w-8 h-8 rounded-full bg-surface-container-highest flex items-center justify-center font-code-mono text-code-mono text-primary-fixed">03</div>
<div>
<div className="font-headline-sm text-headline-sm text-on-surface">Daisuke Sato</div>
<div className="font-code-mono text-code-mono text-on-surface-variant">BMW S1000RR • Tail Marshal</div>
</div>
</div>
<div className="text-right">
<div className="font-telemetry-metric-md text-telemetry-metric-md text-tertiary-fixed-dim">99.4%</div>
<div className="font-code-mono text-code-mono text-outline">Tire Integrity</div>
</div>
</div>
</div>
<div className="mt-space-sm pt-space-sm bg-surface-container-lowest rounded-lg p-space-sm flex items-center justify-between">
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">cell_tower</span>
<span className="font-code-mono text-code-mono text-on-surface">Pack Formation: Diamond Alpha</span>
</div>
<span className="font-code-mono text-code-mono text-secondary-fixed">Health: Nominal</span>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Key Operational Pillars Section */}
<section className="w-full py-space-4xl bg-surface">
<div className="max-w-container-max mx-auto px-gutter-desktop">
<div className="flex flex-col gap-space-xs max-w-2xl mb-space-3xl">
<span className="font-label-caps text-label-caps text-primary-container tracking-normal">Tactical Capabilities</span>
<h2 className="font-headline-xl text-headline-xl text-on-surface">Key Operational Pillars for Clubs</h2>
<p className="font-body-md text-body-md text-on-surface-variant">Standardize ride telemetry, protect group formations, and track hardware readiness across every connected machine.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-space-xl">
{/* Pillar 1 */}
<div className="p-space-xl rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-lg">
<div>
<div className="flex items-center justify-between mb-space-md">
<div className="w-12 h-12 rounded-xl bg-primary-container/10 flex items-center justify-center">
<span className="material-symbols-outlined text-primary-container text-[24px]">groups</span>
</div>
<span className="font-code-mono text-code-mono text-outline">Pillar // 01</span>
</div>
<h3 className="font-headline-lg text-headline-lg text-on-surface mb-space-xs">Central Fleet &amp; Member Roster</h3>
<p className="font-body-md text-body-md text-on-surface-variant mb-space-lg">
Manage hundreds of club members, their registered bikes, emergency contact info, and marshal access permissions with instant profile synchronization.
</p>
</div>
<div className="p-space-md rounded-lg bg-surface-container-lowest">
<div className="flex items-center justify-between font-code-mono text-code-mono text-on-surface-variant mb-space-2xs">
<span>Active Riders Validated</span>
<span className="text-primary-container">482 / 500</span>
</div>
<div className="w-full h-2 bg-surface-container-highest rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-primary-container to-surface-tint w-[92%]"></div>
</div>
</div>
</div>
{/* Pillar 2 */}
<div className="p-space-xl rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-lg">
<div>
<div className="flex items-center justify-between mb-space-md">
<div className="w-12 h-12 rounded-xl bg-secondary-container/10 flex items-center justify-center">
<span className="material-symbols-outlined text-secondary-fixed text-[24px]">military_tech</span>
</div>
<span className="font-code-mono text-code-mono text-outline">Pillar // 02</span>
</div>
<h3 className="font-headline-lg text-headline-lg text-on-surface mb-space-xs">Private Leaderboards &amp; Segment Trophies</h3>
<p className="font-body-md text-body-md text-on-surface-variant mb-space-lg">
Compare verified lean angles, sector lap times, and track speed on private leaderboards with multi-constellation anti-cheat GPS validation.
</p>
</div>
<div className="p-space-md rounded-lg bg-surface-container-lowest flex items-center justify-between">
<div className="flex items-center gap-space-sm">
<span className="material-symbols-outlined text-secondary-fixed text-[22px]">verified</span>
<div>
<div className="font-code-mono text-code-mono text-on-surface">Anti-Cheat Enforced</div>
<div className="font-body-sm text-body-sm text-on-surface-variant">10Hz RTK-grade drift rejection</div>
</div>
</div>
<span className="font-label-caps text-label-caps text-secondary-fixed bg-secondary-container/20 px-space-xs py-1 rounded">Secure</span>
</div>
</div>
{/* Pillar 3 */}
<div className="p-space-xl rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-lg">
<div>
<div className="flex items-center justify-between mb-space-md">
<div className="w-12 h-12 rounded-xl bg-primary-container/10 flex items-center justify-center">
<span className="material-symbols-outlined text-primary-container text-[24px]">radar</span>
</div>
<span className="font-code-mono text-code-mono text-outline">Pillar // 03</span>
</div>
<h3 className="font-headline-lg text-headline-lg text-on-surface mb-space-xs">Road Captain Master Console</h3>
<p className="font-body-md text-body-md text-on-surface-variant mb-space-lg">
Bird's-eye live view of all club pack rides with real-time health, incident warnings, high-G deceleration alerts, and direct marshal dispatch.
</p>
</div>
<div className="p-space-md rounded-lg bg-surface-container-lowest grid grid-cols-2 gap-space-sm">
<div>
<span className="font-code-mono text-code-mono text-outline">Dispatch Status</span>
<div className="font-headline-sm text-headline-sm text-on-surface">3 Marshals Ready</div>
</div>
<div>
<span className="font-code-mono text-code-mono text-outline">Incident Queue</span>
<div className="font-headline-sm text-headline-sm text-secondary-fixed">0 Critical</div>
</div>
</div>
</div>
{/* Pillar 4 */}
<div className="p-space-xl rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-lg">
<div>
<div className="flex items-center justify-between mb-space-md">
<div className="w-12 h-12 rounded-xl bg-tertiary-container/10 flex items-center justify-center">
<span className="material-symbols-outlined text-tertiary-fixed-dim text-[24px]">manufacturing</span>
</div>
<span className="font-code-mono text-code-mono text-outline">Pillar // 04</span>
</div>
<h3 className="font-headline-lg text-headline-lg text-on-surface mb-space-xs">Fleet Maintenance &amp; Engine Telemetry</h3>
<p className="font-body-md text-body-md text-on-surface-variant mb-space-lg">
Track oil life, brake wear estimates, tire heat cycles, and ECU fault codes across member bikes to prevent mechanical DNFs on track and road.
</p>
</div>
<div className="p-space-md rounded-lg bg-surface-container-lowest flex items-center justify-between">
<div className="flex items-center gap-space-sm">
<span className="material-symbols-outlined text-tertiary-fixed-dim text-[22px]">vital_signs</span>
<span className="font-code-mono text-code-mono text-on-surface">CAN-Bus ECU Diagnostics</span>
</div>
<span className="font-code-mono text-code-mono text-tertiary-fixed-dim">100% Health Check</span>
</div>
</div>
</div>
</div>
</section>
{/* Featured Verified Clubs Directory */}
<section className="w-full py-space-4xl bg-surface-container-lowest" id="directory">
<div className="max-w-container-max mx-auto px-gutter-desktop">
<div className="flex flex-col md:flex-row md:items-end justify-between mb-space-3xl gap-space-md">
<div className="flex flex-col gap-space-xs">
<span className="font-label-caps text-label-caps text-primary-fixed-dim tracking-normal">Global Roster</span>
<h2 className="font-headline-xl text-headline-xl text-on-surface">Featured Verified Clubs</h2>
<p className="font-body-md text-body-md text-on-surface-variant">Join elite performance racing syndicates and organized touring packs running live telemetry networks.</p>
</div>
<div className="flex items-center gap-space-xs">
<span className="font-code-mono text-code-mono text-outline">Sort:</span>
<span className="font-label-md text-label-md text-primary-container">Verified High-Performance</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-space-lg">
{/* Club Card 1 */}
<div className="p-space-lg rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-md">
<div className="space-y-space-md">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-1.5 px-space-xs py-1 rounded bg-secondary-container/15 font-label-caps text-label-caps text-secondary-fixed">
<span className="h-1.5 w-1.5 rounded-full bg-secondary-fixed animate-pulse"></span>
2 Active Rides
</span>
<span className="font-code-mono text-code-mono text-outline">CA-09</span>
</div>
<div>
<h3 className="font-headline-md text-headline-md text-on-surface">Apex Pro Track School</h3>
<p className="font-code-mono text-code-mono text-primary-container">San Jose, CA</p>
</div>
<div className="flex flex-wrap gap-space-2xs">
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">240 Members</span>
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">14 Track Days/yr</span>
<span className="px-space-xs py-0.5 rounded bg-primary-container/20 font-code-mono text-code-mono text-primary-fixed">Pro Certified</span>
</div>
<p className="font-body-sm text-body-sm text-on-surface-variant">
Lead Coach: Dave Kinlan (AMA Pro Superbike Alum). Specialized in telemetry-guided apex entry and throttle-trace optimization.
</p>
</div>
<div className="pt-space-lg mt-space-md">
<button className="w-full py-2.5 px-space-md rounded-lg bg-surface-container hover:bg-primary-container hover:text-on-primary-container text-on-surface font-headline-sm text-headline-sm transition-all flex items-center justify-center gap-space-xs">
<span>Request to Join</span>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
</div>
</div>
{/* Club Card 2 */}
<div className="p-space-lg rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-md">
<div className="space-y-space-md">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-1.5 px-space-xs py-1 rounded bg-secondary-container/15 font-label-caps text-label-caps text-secondary-fixed">
<span className="h-1.5 w-1.5 rounded-full bg-secondary-fixed animate-pulse"></span>
4 Active Rides
</span>
<span className="font-code-mono text-code-mono text-outline">CA-01</span>
</div>
<div>
<h3 className="font-headline-md text-headline-md text-on-surface">NorCal Canyon Syndicate</h3>
<p className="font-code-mono text-code-mono text-primary-container">San Francisco, CA</p>
</div>
<div className="flex flex-wrap gap-space-2xs">
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">580 Members</span>
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">Weekly Packs</span>
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">Sport / Superbike</span>
</div>
<p className="font-body-sm text-body-sm text-on-surface-variant">
Road Captain: Ryan 'Hatch' Miller. Precision formation passes, mountain safety monitors, and private Skaggs Springs leaderboards.
</p>
</div>
<div className="pt-space-lg mt-space-md">
<button className="w-full py-2.5 px-space-md rounded-lg bg-surface-container hover:bg-primary-container hover:text-on-primary-container text-on-surface font-headline-sm text-headline-sm transition-all flex items-center justify-center gap-space-xs">
<span>Request to Join</span>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
</div>
</div>
{/* Club Card 3 */}
<div className="p-space-lg rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-md">
<div className="space-y-space-md">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-1.5 px-space-xs py-1 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">
Standby
</span>
<span className="font-code-mono text-code-mono text-outline">WA-04</span>
</div>
<div>
<h3 className="font-headline-md text-headline-md text-on-surface">Pacific Northwest Moto Fleet</h3>
<p className="font-code-mono text-code-mono text-primary-container">Seattle, WA</p>
</div>
<div className="flex flex-wrap gap-space-2xs">
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">310 Members</span>
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">Touring &amp; ADV</span>
<span className="px-space-xs py-0.5 rounded bg-primary-container/20 font-code-mono text-code-mono text-primary-fixed">All-Weather</span>
</div>
<p className="font-body-sm text-body-sm text-on-surface-variant">
Fleet Lead: Sarah Jenkins. Cascade loop expeditions, wet-grip telemetry tracking, and satellite mesh communicator integration.
</p>
</div>
<div className="pt-space-lg mt-space-md">
<button className="w-full py-2.5 px-space-md rounded-lg bg-surface-container hover:bg-primary-container hover:text-on-primary-container text-on-surface font-headline-sm text-headline-sm transition-all flex items-center justify-center gap-space-xs">
<span>Request to Join</span>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
</div>
</div>
{/* Club Card 4 */}
<div className="p-space-lg rounded-xl bg-surface-container-low hover:bg-surface-container transition-all flex flex-col justify-between shadow-md">
<div className="space-y-space-md">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-1.5 px-space-xs py-1 rounded bg-secondary-container/15 font-label-caps text-label-caps text-secondary-fixed">
<span className="h-1.5 w-1.5 rounded-full bg-secondary-fixed animate-pulse"></span>
1 Active Ride
</span>
<span className="font-code-mono text-code-mono text-outline">TX-03</span>
</div>
<div>
<h3 className="font-headline-md text-headline-md text-on-surface">Austin Apex League</h3>
<p className="font-code-mono text-code-mono text-primary-container">Austin, TX</p>
</div>
<div className="flex flex-wrap gap-space-2xs">
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">420 Members</span>
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">Track &amp; Street</span>
<span className="px-space-xs py-0.5 rounded bg-surface-container-highest font-code-mono text-code-mono text-on-surface-variant">COTA Track Days</span>
</div>
<p className="font-body-sm text-body-sm text-on-surface-variant">
Founder: Mateo Alvarez. Regular COTA track allocations, night Hill Country loops, and high-frequency suspension setup telemetry.
</p>
</div>
<div className="pt-space-lg mt-space-md">
<button className="w-full py-2.5 px-space-md rounded-lg bg-surface-container hover:bg-primary-container hover:text-on-primary-container text-on-surface font-headline-sm text-headline-sm transition-all flex items-center justify-center gap-space-xs">
<span>Request to Join</span>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
</div>
</div>
</div>
</div>
</section>
{/* Live Telemetry Radar Preview Panel */}
<section className="w-full py-space-3xl bg-surface">
<div className="max-w-container-max mx-auto px-gutter-desktop">
<div className="p-space-2xl rounded-xl bg-surface-container-low shadow-xl">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-space-xl items-center">
<div className="lg:col-span-6 flex flex-col gap-space-md">
<span className="font-label-caps text-label-caps text-secondary-fixed tracking-normal">Live Marshal Radar</span>
<h3 className="font-headline-xl text-headline-xl text-on-surface">Keep pack formations tighter and track incidents instantly.</h3>
<p className="font-body-md text-body-md text-on-surface-variant">
When a rider slows unexpectedly or triggers an emergency IMU deceleration event, the entire road captain council gets an immediate audio-visual alert with pinpoint GPS coordinates.
</p>
<div className="space-y-space-xs pt-space-xs">
<div className="flex items-center gap-space-sm">
<span className="material-symbols-outlined text-secondary-fixed text-[20px]">check_circle</span>
<span className="font-body-md text-body-md text-on-surface">Automatic peg-scrape hazard mapping for follow riders</span>
</div>
<div className="flex items-center gap-space-sm">
<span className="material-symbols-outlined text-secondary-fixed text-[20px]">check_circle</span>
<span className="font-body-md text-body-md text-on-surface">Anti-straggle pace delta monitoring on multi-tier routes</span>
</div>
<div className="flex items-center gap-space-sm">
<span className="material-symbols-outlined text-secondary-fixed text-[20px]">check_circle</span>
<span className="font-body-md text-body-md text-on-surface">Cross-brand CAN-bus support (Ducati, Yamaha, BMW, KTM, Aprilia)</span>
</div>
</div>
</div>
<div className="lg:col-span-6">
<div className="bg-surface-container-lowest p-space-lg rounded-xl shadow-inner">
<div className="flex items-center justify-between pb-space-sm mb-space-sm">
<span className="font-code-mono text-code-mono text-outline">Telemetry Delta // Sector 2</span>
<span className="font-code-mono text-code-mono text-secondary-fixed">Apex Cloud Active</span>
</div>
{/* Telemetry Sparkline Simulation */}
<div className="h-36 w-full flex items-end gap-1.5 pt-space-sm">
<div className="w-full bg-primary-container/20 rounded-t h-[40%]"></div>
<div className="w-full bg-primary-container/30 rounded-t h-[65%]"></div>
<div className="w-full bg-primary-container/40 rounded-t h-[55%]"></div>
<div className="w-full bg-primary-container/60 rounded-t h-[85%]"></div>
<div className="w-full bg-primary-container/80 rounded-t h-[95%]"></div>
<div className="w-full bg-secondary-fixed rounded-t h-[100%] shadow-[0_0_12px_rgba(96,255,152,0.6)]"></div>
<div className="w-full bg-primary-container/90 rounded-t h-[75%]"></div>
<div className="w-full bg-primary-container/70 rounded-t h-[60%]"></div>
<div className="w-full bg-primary-container/50 rounded-t h-[45%]"></div>
<div className="w-full bg-primary-container/30 rounded-t h-[30%]"></div>
<div className="w-full bg-primary-container/20 rounded-t h-[50%]"></div>
<div className="w-full bg-primary-container/30 rounded-t h-[70%]"></div>
</div>
<div className="grid grid-cols-3 gap-space-xs pt-space-md mt-space-md">
<div>
<span className="font-code-mono text-code-mono text-outline block">Max Speed</span>
<span className="font-headline-sm text-headline-sm text-on-surface">264 km/h</span>
</div>
<div>
<span className="font-code-mono text-code-mono text-outline block">Peak Lean</span>
<span className="font-headline-sm text-headline-sm text-primary-container">57.8° L</span>
</div>
<div>
<span className="font-code-mono text-code-mono text-outline block">G-Force Lat</span>
<span className="font-headline-sm text-headline-sm text-secondary-fixed">1.42 G</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Club Administrative Tier Comparison */}
<section className="w-full py-space-4xl bg-surface-container-lowest" id="register-club">
<div className="max-w-container-max mx-auto px-gutter-desktop">
<div className="text-center max-w-2xl mx-auto mb-space-3xl">
<span className="font-label-caps text-label-caps text-primary-container tracking-normal">Organization Tiers</span>
<h2 className="font-headline-xl text-headline-xl text-on-surface mt-space-2xs">Club Administrative Plans</h2>
<p className="font-body-md text-body-md text-on-surface-variant mt-space-xs">Scalable telemetry, cloud computing, and real-time mesh for grassroots packs up to world-class racing academies.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-space-xl items-stretch">
{/* Tier 1 */}
<div className="p-space-xl rounded-xl bg-surface-container-low flex flex-col justify-between shadow-lg">
<div>
<div className="flex items-center justify-between mb-space-xs">
<span className="font-label-caps text-label-caps text-outline">Entry Fleet</span>
<span className="font-code-mono text-code-mono text-on-surface-variant">Community</span>
</div>
<h3 className="font-headline-lg text-headline-lg text-on-surface">Grassroots Clubs</h3>
<div className="flex items-baseline gap-1 my-space-md">
<span className="font-display-hero text-display-hero text-on-surface">Free</span>
<span className="font-body-sm text-body-sm text-on-surface-variant">/ forever</span>
</div>
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-lg">
Essential fleet synchronization for local casual packs and weekend canyon riders.
</p>
<div className="space-y-space-sm pt-space-xs">
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-outline text-[18px]">check</span>
<span className="font-body-sm text-body-sm text-on-surface">Up to 15 riders per pack</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-outline text-[18px]">check</span>
<span className="font-body-sm text-body-sm text-on-surface">Standard GPS live tracking</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-outline text-[18px]">check</span>
<span className="font-body-sm text-body-sm text-on-surface">Basic lean angle logs</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-outline text-[18px]">check</span>
<span className="font-body-sm text-body-sm text-on-surface">Public club directory listing</span>
</div>
</div>
</div>
<div className="pt-space-xl mt-space-md">
<button className="w-full py-3 px-space-md rounded-lg bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-headline-sm transition-all">
Deploy Grassroots
</button>
</div>
</div>
{/* Tier 2 (Highlighted) */}
<div className="p-space-xl rounded-xl bg-surface-container flex flex-col justify-between shadow-2xl relative">
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-space-sm py-0.5 rounded-full bg-primary-container text-on-primary-container font-label-caps text-label-caps tracking-normal shadow-md">
Most Popular Syndicate
</div>
<div>
<div className="flex items-center justify-between mb-space-xs">
<span className="font-label-caps text-label-caps text-primary-container">Pro Formation</span>
<span className="font-code-mono text-code-mono text-primary-fixed">Verified Badge</span>
</div>
<h3 className="font-headline-lg text-headline-lg text-on-surface">Verified Syndicate</h3>
<div className="flex items-baseline gap-1 my-space-md">
<span className="font-display-hero text-display-hero text-primary-container">$49</span>
<span className="font-body-sm text-body-sm text-on-surface-variant">/ month</span>
</div>
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-lg">
Engineered for established sportbike clubs, track groups, and high-frequency touring units.
</p>
<div className="space-y-space-sm pt-space-xs">
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-primary-container text-[18px]">check_circle</span>
<span className="font-body-sm text-body-sm text-on-surface">Up to 100 verified riders</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-primary-container text-[18px]">check_circle</span>
<span className="font-body-sm text-body-sm text-on-surface">Private telemetry leaderboards</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-primary-container text-[18px]">check_circle</span>
<span className="font-body-sm text-body-sm text-on-surface">Road captain emergency marshal beacons</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-primary-container text-[18px]">check_circle</span>
<span className="font-body-sm text-body-sm text-on-surface">10Hz high-frequency CAN-bus sync</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-primary-container text-[18px]">check_circle</span>
<span className="font-body-sm text-body-sm text-on-surface">Automated crash &amp; peg-scrape alerts</span>
</div>
</div>
</div>
<div className="pt-space-xl mt-space-md">
<button className="w-full py-3 px-space-md rounded-lg bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm shadow-lg hover:shadow-[0_0_24px_rgba(0,242,254,0.45)] transition-all">
Initialize Syndicate
</button>
</div>
</div>
{/* Tier 3 */}
<div className="p-space-xl rounded-xl bg-surface-container-low flex flex-col justify-between shadow-lg">
<div>
<div className="flex items-center justify-between mb-space-xs">
<span className="font-label-caps text-label-caps text-secondary-fixed">Enterprise Grid</span>
<span className="font-code-mono text-code-mono text-secondary-fixed">FIM Data Spec</span>
</div>
<h3 className="font-headline-lg text-headline-lg text-on-surface">Enterprise Academy</h3>
<div className="flex items-baseline gap-1 my-space-md">
<span className="font-display-hero text-display-hero text-secondary-fixed">$199</span>
<span className="font-body-sm text-body-sm text-on-surface-variant">/ month</span>
</div>
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-lg">
Complete professional infrastructure for track schools, race teams, and commercial fleets.
</p>
<div className="space-y-space-sm pt-space-xs">
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
<span className="font-body-sm text-body-sm text-on-surface">Unlimited riders and fleet assets</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
<span className="font-body-sm text-body-sm text-on-surface">Live track telemetry broadcast link</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
<span className="font-body-sm text-body-sm text-on-surface">Telemetry REST API &amp; Webhook access</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
<span className="font-body-sm text-body-sm text-on-surface">Custom club branding &amp; sub-domain</span>
</div>
<div className="flex items-center gap-space-xs">
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
<span className="font-body-sm text-body-sm text-on-surface">Dedicated fleet engineer support</span>
</div>
</div>
</div>
<div className="pt-space-xl mt-space-md">
<button className="w-full py-3 px-space-md rounded-lg bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-headline-sm transition-all">
Contact Race Operations
</button>
</div>
</div>
</div>
</div>
</section>
{/* Interactive Club Registration Callout */}
<section className="w-full py-space-3xl bg-surface">
<div className="max-w-container-max mx-auto px-gutter-desktop">
<div className="p-space-2xl rounded-xl bg-gradient-to-r from-surface-container-low via-surface-container to-surface-container-high shadow-xl flex flex-col md:flex-row items-center justify-between gap-space-xl">
<div className="flex flex-col gap-space-xs max-w-xl">
<span className="font-label-caps text-label-caps text-primary-container tracking-normal">Ready to Integrate?</span>
<h3 className="font-headline-xl text-headline-xl text-on-surface">Connect your club to the MotorSignal Grid today.</h3>
<p className="font-body-md text-body-md text-on-surface-variant">Setup takes less than 3 minutes. Distribute invite tokens to your road marshals and member bikes.</p>
</div>
<div className="flex items-center gap-space-md w-full md:w-auto">
<button className="w-full md:w-auto px-space-xl py-3.5 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm shadow-xl hover:shadow-[0_0_24px_rgba(0,242,254,0.45)] transition-all flex items-center justify-center gap-space-xs">
<span className="material-symbols-outlined text-[20px]">add_moderator</span>
<span>Create Club Hub</span>
</button>
</div>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@ -0,0 +1,252 @@
'use client';
import React from 'react';
import { ClubMember, ClubUpcomingRide, UserPortalPermissions } from './types';
import { EmptyState } from './empty-state';
interface AttentionSectionProps {
pendingApplications: ClubMember[];
upcomingRides: ClubUpcomingRide[];
permissions: UserPortalPermissions;
onApproveApplication?: (uid: string) => void;
onReviewApplication?: (uid: string) => void;
onAssignSweep?: (rideId: string) => void;
onMarkChecked?: () => void;
}
export function AttentionSection({
pendingApplications,
upcomingRides,
permissions,
onApproveApplication,
onReviewApplication,
onAssignSweep,
onMarkChecked,
}: AttentionSectionProps) {
// Find any ride needing assignment
const rideNeedingSweep = upcomingRides.find(
(r) => r.sweepStatus === 'needs_assignment' || r.sweepStatus === 'vacant'
);
const pendingAppsCount = pendingApplications.length;
const totalPendingActions = (pendingAppsCount > 0 ? 1 : 0) + (rideNeedingSweep ? 1 : 0);
return (
<section className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-lg">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${
totalPendingActions > 0 ? 'bg-error animate-pulse' : 'bg-brand-cyan'
}`}
/>
<h2 className="font-headline text-base font-bold text-white tracking-wide">
Attention Required
</h2>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-slate-400">
{totalPendingActions} pending {totalPendingActions === 1 ? 'action' : 'actions'}
</span>
{onMarkChecked && (
<button
onClick={onMarkChecked}
className="px-2.5 py-1 rounded-lg bg-surface-container hover:bg-surface-container-high text-slate-300 hover:text-white font-mono text-xs border border-brand-border/60 transition-colors"
>
Mark Checked
</button>
)}
</div>
</div>
{/* 3 Triage Cards Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Card 1: Membership Applications */}
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
<div>
<div className="flex items-center justify-between pb-2.5 border-b border-brand-border/40">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[18px] text-brand-cyan">
person_add
</span>
<span className="font-body text-xs sm:text-sm font-semibold text-white">
Membership Applications
</span>
</div>
{pendingAppsCount > 0 ? (
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30">
{pendingAppsCount} Pending
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/40">0 Pending</span>
)}
</div>
<p className="font-body text-xs text-slate-400 mt-2 mb-3">
Membership applications awaiting review.
</p>
{pendingAppsCount === 0 ? (
<EmptyState
icon="how_to_reg"
title="No Pending Applications"
description="No membership applications currently require review."
className="py-4"
/>
) : (
<div className="space-y-2">
{pendingApplications.slice(0, 3).map((app) => (
<div
key={app.uid}
className="p-2.5 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-between gap-2"
>
<div className="min-w-0">
<div className="font-body text-xs font-semibold text-white truncate">
{app.displayName ?? 'New Rider'}
</div>
<div className="font-mono text-[10px] text-slate-400">
{app.requestedAt ?? app.joinedAt ?? 'Recently'}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{permissions.canApproveApplications && (
<button
onClick={() => onApproveApplication?.(app.uid)}
className="px-2.5 py-1 rounded-lg bg-primary-container text-on-primary-container font-bold text-[11px] hover:brightness-110 shadow-[0_0_8px_rgba(0,242,254,0.2)] transition-all cursor-pointer"
>
Approve
</button>
)}
<button
onClick={() => onReviewApplication?.(app.uid)}
className="px-2 py-1 rounded-lg bg-surface-container hover:bg-surface-container-high text-slate-300 font-mono text-[10px] border border-brand-border/60 transition-colors cursor-pointer"
>
Review
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="mt-3 pt-2 border-t border-brand-border/40 flex items-center justify-between text-slate-400 font-mono text-[10px]">
<span>Triage Status</span>
<span>{pendingAppsCount === 0 ? 'All Cleared' : `${pendingAppsCount} Actionable`}</span>
</div>
</div>
{/* Card 2: Moderation Flag / Wall Posts (Preserving deferred backend architecture) */}
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
<div>
<div className="flex items-center justify-between pb-2.5 border-b border-brand-border/40">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[17px] text-amber-400">gavel</span>
<span className="font-body text-xs sm:text-sm font-semibold text-white">
Reported Club Wall Post
</span>
</div>
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/40">Deferred</span>
</div>
<p className="font-body text-xs text-slate-400 mt-2 mb-3">
Club Wall moderation and content flagging module.
</p>
<EmptyState
icon="verified"
title="No Reported Posts"
description="No content flags pending. Club Wall moderation backend is scheduled for future release."
className="py-4"
/>
</div>
<div className="mt-3 pt-2 border-t border-brand-border/40 flex items-center justify-between text-slate-400 font-mono text-[10px]">
<span>Moderation Queue</span>
<span>Cleared</span>
</div>
</div>
{/* Card 3: Upcoming Ride Assignment */}
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
<div>
<div className="flex items-center justify-between pb-2.5 border-b border-brand-border/40">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[17px] text-brand-cyan">
assignment_ind
</span>
<span className="font-body text-xs sm:text-sm font-semibold text-white">
Upcoming Ride Assignment
</span>
</div>
{rideNeedingSweep ? (
<span className="px-2 py-0.5 rounded-full bg-brand-cyan/15 text-brand-cyan font-mono text-[10px] font-semibold border border-brand-cyan/30">
Action Needed
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/40">Assigned</span>
)}
</div>
<p className="font-body text-xs text-slate-400 mt-2 mb-3">
Safety position check for scheduled group rides:
</p>
{rideNeedingSweep ? (
<div className="rounded bg-surface-container-high border border-outline-variant/60 p-2.5 space-y-2">
<div className="flex items-center justify-between">
<span className="font-body text-xs text-on-surface font-semibold truncate max-w-[180px]">
{rideNeedingSweep.title}
</span>
<span className="font-mono text-[10px] text-primary shrink-0">
{rideNeedingSweep.scheduledStartAt.split('·')[0].trim()}
</span>
</div>
<div className="grid grid-cols-2 gap-1.5 font-mono text-[11px] text-outline">
<div>
Riders Going: <span className="text-on-surface">{rideNeedingSweep.confirmedCount}</span>
</div>
<div>
Road Captain: <span className="text-on-surface">{rideNeedingSweep.roadCaptainName}</span>
</div>
<div className="col-span-2">
Sweep: <span className="text-error font-medium">Vacant</span>
</div>
</div>
<div className="p-1.5 rounded bg-surface-container-lowest/80 font-body text-[11px] text-on-surface-variant border border-outline-variant/40">
Sweep position is currently unassigned for this ride.
</div>
</div>
) : (
<EmptyState
icon="check_circle"
title="Rosters Assigned"
description="All scheduled rides have designated Road Captain and Sweep positions."
className="py-4"
/>
)}
</div>
<div className="mt-3 pt-2">
{rideNeedingSweep && permissions.canPlanRides ? (
<button
onClick={() => onAssignSweep?.(rideNeedingSweep.id)}
className="w-full h-8 rounded-lg bg-secondary-fixed text-on-secondary-fixed font-body text-xs font-bold hover:brightness-110 transition-all flex items-center justify-center gap-1.5 shadow-sm cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">person_pin_circle</span>
<span>Assign Sweep</span>
</button>
) : (
<div className="font-mono text-[10px] text-outline text-right">
Positions Stable
</div>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,100 @@
'use client';
import React, { useState } from 'react';
import { ClubActivityItem } from './types';
import { EmptyState } from './empty-state';
interface ClubActivityListProps {
activities?: ClubActivityItem[];
}
export function ClubActivityList({ activities = [] }: ClubActivityListProps) {
const [filter, setFilter] = useState<'all' | 'membership' | 'ride' | 'wall' | 'admin'>('all');
const filtered = activities.filter((item) => {
if (filter === 'all') return true;
return item.type === filter;
});
return (
<section className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-lg">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 className="font-headline text-lg sm:text-xl font-bold text-white tracking-tight">
Club Activity Stream
</h2>
<p className="font-body text-xs sm:text-sm text-slate-400 mt-0.5">
Recent events, memberships, and administrative actions.
</p>
</div>
{/* Filter Pills */}
<div className="flex items-center gap-1 overflow-x-auto p-1 rounded-xl bg-surface-container border border-brand-border/60">
{(
[
{ key: 'all', label: 'All Activity' },
{ key: 'membership', label: 'Memberships' },
{ key: 'ride', label: 'Rides' },
{ key: 'wall', label: 'Wall' },
{ key: 'admin', label: 'Administrative' },
] as const
).map((tab) => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`px-3 py-1 rounded-lg font-mono text-xs transition-colors whitespace-nowrap cursor-pointer ${
filter === tab.key
? 'bg-brand-cyan text-obsidian-darkest font-bold shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{filtered.length === 0 ? (
<EmptyState
icon="event_note"
title="No Recent Activity"
description="No recent activity logged for this filter. Activity stream integration scheduled for future release."
className="py-6"
/>
) : (
<div className="space-y-2">
{filtered.map((item) => {
const iconMap = {
membership: 'how_to_reg',
ride: 'add_road',
wall: 'forum',
admin: 'tune',
};
return (
<div
key={item.id}
className="flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded-xl bg-surface-container/60 border border-brand-border/60 hover:bg-surface-container-high/80 transition-colors gap-2"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-center text-brand-cyan shrink-0">
<span className="material-symbols-outlined text-[17px]">
{iconMap[item.type] || 'circle'}
</span>
</div>
<div className="min-w-0 font-body text-xs text-slate-200">
<span className="font-semibold text-white">{item.actorName}</span>{' '}
<span>{item.actionText}</span>
</div>
</div>
<div className="font-mono text-[11px] text-slate-400 shrink-0 self-end sm:self-center">
{item.timestamp}
</div>
</div>
);
})}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,168 @@
'use client';
import React from 'react';
import { Club, UserPortalPermissions } from './types';
interface ClubMastheadProps {
club: Club;
permissions: UserPortalPermissions;
onPlanRide?: () => void;
onInviteMember?: () => void;
onOpenSettings?: () => void;
}
export function ClubMasthead({
club,
permissions,
onPlanRide,
onInviteMember,
onOpenSettings,
}: ClubMastheadProps) {
// Deterministic 2-4 character monogram
const monogram = club.name
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase())
.slice(0, 3)
.join('') || 'MS';
const visibilityLabel = club.visibility === 'public' ? 'Public' : 'Private';
const admissionLabel =
club.admissionPolicy === 'inviteOnly'
? 'Invite Only'
: club.admissionPolicy === 'open'
? 'Open to Join'
: 'Request to Join';
return (
<section className="relative rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 shadow-xl overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-brand-cyan/80 via-blue-500/40 to-transparent"></div>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
{/* Left: Identity & Monogram */}
<div className="flex flex-col sm:flex-row sm:items-center gap-5 min-w-0">
{/* Precision Hex Monogram Emblem */}
<div className="relative w-16 h-16 sm:w-20 sm:h-20 shrink-0 flex items-center justify-center rounded-2xl bg-gradient-to-br from-brand-borderLight to-slate-900 border border-brand-cyan/40 shadow-[0_0_20px_rgba(0,242,254,0.25)]">
<svg
className="w-12 h-12 sm:w-14 sm:h-14 text-brand-cyan"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 48 48"
>
<polygon
points="24 4, 43 14, 43 34, 24 44, 5 34, 5 14"
stroke="currentColor"
strokeWidth="1.8"
/>
</svg>
<span className="absolute font-headline font-extrabold text-xs sm:text-sm tracking-wider text-white">
{monogram}
</span>
</div>
{/* Titles and Club Attributes */}
<div className="flex flex-col min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h1 className="font-headline text-2xl sm:text-3xl font-extrabold text-white tracking-tight truncate">
{club.name}
</h1>
<span className="px-2.5 py-0.5 rounded-full bg-surface-container border border-brand-border/60 text-slate-300 font-mono text-[11px] font-medium tracking-wider">
{visibilityLabel}
</span>
<span className="px-2.5 py-0.5 rounded-full bg-brand-cyan/10 border border-brand-cyan/30 text-brand-cyan font-mono text-[11px] font-semibold tracking-wider">
{admissionLabel}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 font-mono text-xs text-slate-400">
<span className="text-white font-medium">@{club.slug}</span>
{club.homeRegion && (
<>
<span className="text-outline-variant">•</span>
<span>{club.homeRegion}</span>
</>
)}
{club.foundedYear && (
<>
<span className="text-outline-variant">•</span>
<span>Founded {club.foundedYear}</span>
</>
)}
{club.motorcycleFocus && (
<>
<span className="text-outline-variant">•</span>
<span>{club.motorcycleFocus}</span>
</>
)}
</div>
{/* Founder Attribution Sub-strip */}
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-400 font-body">
<div className="flex items-center gap-1.5">
<span className="text-slate-500">Founder:</span>
<span className="text-slate-200 font-medium">
{club.founderName ?? 'Marcus Vance'}
</span>
{club.founderHandle && (
<span className="font-mono text-[11px] text-brand-cyan">
({club.founderHandle})
</span>
)}
</div>
<span className="text-outline-variant">•</span>
<div className="flex items-center gap-1.5">
<span className="text-slate-500">Club Rank:</span>
<span className="px-2 py-0.5 rounded-full bg-surface-container-high text-slate-200 font-mono text-[11px] font-medium border border-outline-variant/60">
{club.founderRank ?? 'President'}
</span>
</div>
<span className="text-outline-variant">•</span>
<div className="flex items-center gap-1.5">
<span className="text-slate-500">Administrative Role:</span>
<span className="px-2 py-0.5 rounded-full bg-primary-container/15 text-primary-container font-mono text-[11px] font-semibold border border-primary-container/30">
{permissions.role.charAt(0).toUpperCase() + permissions.role.slice(1)}
</span>
</div>
</div>
</div>
</div>
{/* Right: Founder Actions (Role-gated) */}
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2.5 shrink-0">
{permissions.canPlanRides && (
<button
onClick={onPlanRide}
className="h-10 px-4 rounded-xl bg-primary-container text-on-primary-container font-body text-xs sm:text-sm font-bold hover:brightness-110 shadow-[0_0_16px_rgba(0,242,254,0.3)] transition-all flex items-center gap-2 cursor-pointer"
>
<span className="material-symbols-outlined text-[18px]">add_road</span>
<span>Plan Club Ride</span>
</button>
)}
{permissions.canInviteMembers && (
<button
onClick={onInviteMember}
className="h-10 px-4 rounded-xl bg-surface-container border border-outline-variant/60 text-slate-200 hover:text-white hover:bg-surface-container-high hover:border-brand-cyan/40 transition-all font-body text-xs sm:text-sm font-medium flex items-center gap-2 cursor-pointer"
>
<span className="material-symbols-outlined text-[18px]">person_add</span>
<span>Invite Member</span>
</button>
)}
{permissions.canManageSettings && (
<button
onClick={onOpenSettings}
aria-label="Club Settings"
title="Club Settings"
className="h-10 w-10 rounded-xl bg-surface-container border border-outline-variant/60 text-slate-400 hover:text-white hover:bg-surface-container-high hover:border-brand-cyan/40 transition-all flex items-center justify-center cursor-pointer"
>
<span className="material-symbols-outlined text-[20px]">tune</span>
</button>
)}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,222 @@
'use client';
import React from 'react';
import { ClubUpcomingRide, UserPortalPermissions } from './types';
import { EmptyState } from './empty-state';
interface UpcomingRidesSectionProps {
rides: ClubUpcomingRide[];
permissions: UserPortalPermissions;
onPlanRide?: () => void;
onManageRide?: (rideId: string) => void;
onViewRoster?: (rideId: string) => void;
onViewRoute?: (routeId?: string) => void;
}
export function ClubRideCard({
ride,
permissions,
onManageRide,
onViewRoster,
onViewRoute,
}: {
ride: ClubUpcomingRide;
permissions: UserPortalPermissions;
onManageRide?: (rideId: string) => void;
onViewRoster?: (rideId: string) => void;
onViewRoute?: (routeId?: string) => void;
}) {
const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75;
return (
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col justify-between transition-all hover:border-brand-cyan/50 shadow-lg">
<div>
{/* Top Metadata Row */}
<div className="flex flex-wrap items-start justify-between gap-2 pb-2">
<div>
<div className="flex items-center gap-2 font-mono text-xs text-brand-cyan">
<span className="w-2 h-2 rounded-full bg-brand-cyan animate-pulse" />
<span>{ride.scheduledStartAt}</span>
<span className="text-slate-500">•</span>
<span className="text-slate-400">Ride ID: {ride.rideIdentifier}</span>
</div>
<h3 className="font-headline text-lg sm:text-xl font-bold text-white mt-1">
{ride.title}
</h3>
</div>
<span className="px-2.5 py-1 rounded-lg bg-surface-container-high text-slate-300 font-mono text-[11px] font-medium border border-brand-border/60">
{ride.paceClassification}
</span>
</div>
{/* Attributes Row */}
<div className="grid grid-cols-3 gap-2 py-2.5 bg-surface-container-low/80 rounded-xl px-3.5 my-3 border border-brand-border/60">
<div>
<div className="font-mono text-[10px] text-slate-400">Distance</div>
<div className="font-headline text-sm font-semibold text-slate-100 mt-0.5">
{ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'}
</div>
</div>
<div>
<div className="font-mono text-[10px] text-slate-400">Duration</div>
<div className="font-headline text-sm font-semibold text-slate-100 mt-0.5">
{ride.durationString ?? '--'}
</div>
</div>
<div>
<div className="font-mono text-[10px] text-slate-400">Pace Classification</div>
<div className="font-headline text-sm font-semibold text-brand-cyan mt-0.5">
{ride.paceClassification}
</div>
</div>
</div>
{/* Roster & Road Captain Details */}
<div className="space-y-2">
<div className="flex items-center justify-between font-mono text-xs">
<span className="text-slate-400">Riders: {ride.confirmedCount} going</span>
{ride.pendingCount > 0 ? (
<span className="text-amber-400 font-medium">{ride.pendingCount} requests pending</span>
) : ride.capacity ? (
<span className="text-brand-cyan font-medium">
{Math.max(0, ride.capacity - ride.confirmedCount)} open spots
</span>
) : null}
</div>
<div className="w-full h-1.5 rounded bg-surface-container-high overflow-hidden" aria-hidden="true">
<div
className={`h-full rounded transition-all duration-300 ${
ride.pendingCount > 0 ? 'bg-amber-400' : 'bg-brand-cyan'
}`}
style={{ width: `${capacityPercent}%` }}
/>
</div>
<div className="flex flex-wrap items-center justify-between pt-1 font-body text-xs text-slate-300">
<div className="flex items-center gap-1.5">
<span className="text-slate-400">Road Captain:</span>
<span className="text-slate-100 font-medium">{ride.roadCaptainName}</span>
{ride.roadCaptainMotorcycle && (
<span className="font-mono text-[11px] text-slate-400">
({ride.roadCaptainMotorcycle})
</span>
)}
</div>
{ride.sweepStatus === 'needs_assignment' ? (
<div className="font-mono text-xs text-error font-medium">Sweep: Needs Assignment</div>
) : ride.briefingTime ? (
<div className="font-mono text-xs text-brand-cyan">Briefing: {ride.briefingTime}</div>
) : null}
</div>
</div>
{/* Section 15: Verbatim Live Location Privacy Policy Notice */}
<div className="mt-3.5 p-2.5 rounded-xl bg-surface-container/60 border border-brand-border/60 text-slate-400 font-body text-[11px] flex items-start gap-2">
<span className="material-symbols-outlined text-[15px] text-brand-cyan shrink-0 mt-0.5">
info
</span>
<span>
Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
</span>
</div>
</div>
{/* Actions */}
<div className="mt-4 pt-3 border-t border-brand-border/60 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{permissions.canPlanRides ? (
<button
onClick={() => onManageRide?.(ride.id)}
className="h-9 px-3.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-sm transition-all cursor-pointer"
>
Manage Ride
</button>
) : (
<button
onClick={() => onViewRoster?.(ride.id)}
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors cursor-pointer"
>
View Ride
</button>
)}
<button
onClick={() => onViewRoster?.(ride.id)}
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors cursor-pointer"
>
View Roster ({ride.confirmedCount})
</button>
</div>
<button
onClick={() => onViewRoute?.(ride.routeId ?? undefined)}
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-300 hover:text-white font-body text-xs flex items-center gap-1.5 transition-colors cursor-pointer"
>
<span className="material-symbols-outlined text-[15px] text-brand-cyan">map</span>
<span>View Route</span>
</button>
</div>
</div>
);
}
export function UpcomingRidesSection({
rides,
permissions,
onPlanRide,
onManageRide,
onViewRoster,
onViewRoute,
}: UpcomingRidesSectionProps) {
return (
<section className="space-y-4" aria-label="Upcoming Club Rides">
<div className="flex items-center justify-between">
<div>
<h2 className="font-headline text-base font-semibold text-on-surface tracking-tight">
Upcoming Club Rides
</h2>
<p className="font-body text-xs text-outline">
Scheduled Club Rides, routes, participants, and ride assignments.
</p>
</div>
{rides.length > 0 && (
<button
onClick={() => onViewRoster?.('all')}
className="font-mono text-xs text-brand-cyan hover:underline flex items-center gap-1 cursor-pointer"
>
<span>Full Ride Schedule</span>
<span className="material-symbols-outlined text-[13px]">arrow_forward</span>
</button>
)}
</div>
{rides.length === 0 ? (
<EmptyState
icon="route"
title="No Club Rides are currently scheduled."
description="Plan a group route, designate pacelines, and invite club riders."
actionLabel={permissions.canPlanRides ? 'Plan Club Ride' : undefined}
onAction={permissions.canPlanRides ? onPlanRide : undefined}
className="py-10"
/>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{rides.map((ride) => (
<ClubRideCard
key={ride.id}
ride={ride}
permissions={permissions}
onManageRide={onManageRide}
onViewRoster={onViewRoster}
onViewRoute={onViewRoute}
/>
))}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import React from 'react';
interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
export function EmptyState({
icon = 'inbox',
title,
description,
actionLabel,
onAction,
className = '',
}: EmptyStateProps) {
return (
<div
className={`rounded-xl bg-surface-container/60 border border-outline-variant/50 p-6 flex flex-col items-center justify-center text-center ${className}`}
>
<div className="w-11 h-11 rounded-2xl bg-surface-container-high border border-outline-variant/60 flex items-center justify-center text-brand-cyan mb-3 shadow-inner">
<span className="material-symbols-outlined text-[22px]">{icon}</span>
</div>
<h4 className="font-headline text-sm sm:text-base font-bold text-white mb-1">{title}</h4>
{description && (
<p className="font-body text-xs text-slate-400 max-w-sm mb-4">{description}</p>
)}
{actionLabel && onAction && (
<button
onClick={onAction}
className="h-9 px-4 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all inline-flex items-center gap-1.5 cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">add</span>
<span>{actionLabel}</span>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,690 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { Club, UserPortalPermissions } from './types';
import {
ClubApplication,
fetchClubApplications,
approveClubApplication,
declineClubApplication,
sanitizePortalError,
} from '../../lib/club-data';
interface FounderApplicationsProps {
club: Club;
permissions: UserPortalPermissions;
callerUid: string;
isDemoFixture?: boolean;
}
export function FounderApplications({
club,
permissions,
callerUid,
isDemoFixture = false,
}: FounderApplicationsProps) {
const [applications, setApplications] = useState<ClubApplication[]>([]);
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'name'>('newest');
// Mutation and modal states
const [mutatingUid, setMutatingUid] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [approveModalApplicant, setApproveModalApplicant] = useState<ClubApplication | null>(null);
const [declineModalApplicant, setDeclineModalApplicant] = useState<ClubApplication | null>(null);
// Local member count to reflect approval transitions immediately
const [localMemberCount, setLocalMemberCount] = useState(club.memberCount);
async function loadApplications() {
setLoading(true);
setError(null);
try {
const data = await fetchClubApplications(club.id, callerUid, {
allowDemoPreview: isDemoFixture,
});
setApplications(data);
if (data.length > 0) {
setSelectedApplicantUid((prev) => prev ?? data[0].uid);
}
} catch (err) {
setError(sanitizePortalError(err, "Applications couldn't be loaded."));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadApplications();
}, [club.id, callerUid, isDemoFixture]);
// Filtered and sorted applications
const filteredApplications = useMemo(() => {
let list = [...applications];
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
list = list.filter((app) => {
const nameMatch = app.displayName.toLowerCase().includes(q);
const handleMatch = app.handle ? app.handle.toLowerCase().includes(q) : false;
return nameMatch || handleMatch;
});
}
list.sort((a, b) => {
if (sortBy === 'newest') {
return new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime();
}
if (sortBy === 'oldest') {
return new Date(a.requestedAt).getTime() - new Date(b.requestedAt).getTime();
}
return a.displayName.localeCompare(b.displayName);
});
return list;
}, [applications, searchQuery, sortBy]);
const selectedApplicant = useMemo(() => {
if (!selectedApplicantUid) return filteredApplications[0] || null;
return applications.find((a) => a.uid === selectedApplicantUid) || filteredApplications[0] || null;
}, [applications, filteredApplications, selectedApplicantUid]);
function getMonogram(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return 'CA';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function formatDate(isoString: string) {
try {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return isoString;
}
}
async function handleApprove(applicant: ClubApplication) {
if (mutatingUid) return;
setMutatingUid(applicant.uid);
setActionError(null);
try {
if (!isDemoFixture) {
await approveClubApplication(club.id, callerUid, applicant.uid);
}
// Remove from list and increment member count
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid));
setLocalMemberCount((c) => c + 1);
setApproveModalApplicant(null);
// Select next applicant if the approved one was selected
if (selectedApplicantUid === applicant.uid) {
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setSelectedApplicantUid(remaining[0]?.uid || null);
}
} catch (err) {
setActionError(sanitizePortalError(err, 'Unable to approve application. Please try again.'));
} finally {
setMutatingUid(null);
}
}
async function handleDecline(applicant: ClubApplication) {
if (mutatingUid) return;
setMutatingUid(applicant.uid);
setActionError(null);
try {
if (!isDemoFixture) {
await declineClubApplication(club.id, callerUid, applicant.uid);
}
// Remove from list (non-destructive in Firestore, memberCount delta 0)
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid));
setDeclineModalApplicant(null);
// Select next applicant if the declined one was selected
if (selectedApplicantUid === applicant.uid) {
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setSelectedApplicantUid(remaining[0]?.uid || null);
}
} catch (err) {
setActionError(sanitizePortalError(err, 'Unable to decline application. Please try again.'));
} finally {
setMutatingUid(null);
}
}
return (
<div className="space-y-6">
{/* 1. PAGE HEADER */}
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
<div>
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-brand-cyan tracking-widest bg-brand-cyan/10 px-2.5 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
Precision, Not Aggression
</span>
<span className="font-mono text-xs text-slate-400 tracking-wider">
// Membership Triage
</span>
</div>
<div className="flex items-center gap-3 mt-2">
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-white tracking-tight">
Applications
</h1>
<span
className="px-2.5 py-0.5 rounded-full text-xs font-mono font-semibold bg-amber-500/15 border border-amber-500/40 text-amber-400"
aria-label={`${applications.length} pending applications`}
>
{applications.length} PENDING
</span>
</div>
<p className="font-body text-sm text-slate-400 mt-1">Review riders requesting to join this Club.</p>
</div>
</section>
{/* 2. SUMMARY METRIC CARDS ROW */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Pending */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-amber-400/50 via-transparent to-transparent" />
<div className="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
<span>Pending</span>
<span className="w-2 h-2 rounded-full bg-amber-400 shadow-[0_0_8px_rgba(245,158,11,0.5)]" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-headline font-bold text-2xl sm:text-3xl text-white">{applications.length}</span>
</div>
<div className="mt-1 text-[11px] font-mono text-slate-400">Awaiting review</div>
</div>
{/* Card 2: Admission Policy */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
<span>Admission Policy</span>
<span className="w-2 h-2 rounded-full bg-brand-cyan shadow-[0_0_8px_rgba(0,242,254,0.5)]" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-headline font-bold text-xl sm:text-2xl text-white capitalize font-mono">
{club.admissionPolicy}
</span>
</div>
<div className="mt-1 text-[11px] font-mono text-slate-400">Club join governance rule</div>
</div>
{/* Card 3: Active Members */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-emerald-400/50 via-transparent to-transparent" />
<div className="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
<span>Active Members</span>
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-headline font-bold text-2xl sm:text-3xl text-emerald-400">{localMemberCount}</span>
</div>
<div className="mt-1 text-[11px] font-mono text-slate-400">Current club roster</div>
</div>
{/* Card 4: Reviewer Authority */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-lg relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-slate-400/50 via-transparent to-transparent" />
<div className="flex items-center justify-between text-slate-400 text-xs font-mono tracking-wider">
<span>Review Authority</span>
<span className="w-2 h-2 rounded-full bg-slate-400" />
</div>
<div className="mt-2 flex items-baseline space-x-2">
<span className="font-headline font-bold text-xl sm:text-2xl text-slate-200 font-mono">
{permissions.role === 'owner' ? 'Owner Access' : 'Admin Access'}
</span>
</div>
<div className="mt-1 text-[11px] font-mono text-slate-400">Software administrative rights</div>
</div>
</div>
{/* Action Error Alert */}
{actionError && (
<div className="p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center justify-between">
<span>{actionError}</span>
<button
onClick={() => setActionError(null)}
className="text-slate-400 hover:text-white text-xs font-mono ml-3 p-1"
aria-label="Dismiss error"
>
✕
</button>
</div>
)}
{/* 3. FILTER & SEARCH TOOLBAR */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-3.5 flex flex-wrap items-center justify-between gap-3 shadow-md">
{/* Filter Tab */}
<div className="flex items-center space-x-1.5">
<button
type="button"
className="px-3.5 py-1.5 rounded-xl bg-brand-cyan/15 border border-brand-cyan/40 text-brand-cyan text-xs font-semibold shadow-sm transition-colors flex items-center space-x-2"
>
<span>All Pending</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-mono bg-black/40 text-brand-cyan font-bold border border-brand-cyan/30">
{applications.length}
</span>
</button>
</div>
{/* Controls Right */}
<div className="flex flex-1 sm:flex-initial items-center space-x-2.5">
{/* Text Search Bar */}
<div className="relative flex-1 sm:w-64">
<input
type="text"
placeholder="Search applicants by name or @handle..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-9 pl-9 pr-8 rounded-xl bg-surface-container-low/90 border border-brand-border/80 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan transition-colors"
aria-label="Search applicants"
/>
<svg
className="w-4 h-4 text-slate-500 absolute left-3 top-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-2.5 text-slate-500 hover:text-white text-xs p-0.5"
aria-label="Clear search"
>
✕
</button>
)}
</div>
{/* Sort By Dropdown */}
<div className="flex items-center space-x-1.5 bg-surface-container-low/90 border border-brand-border/80 rounded-xl px-3 h-9 text-xs text-slate-300">
<span className="text-[10px] font-mono text-slate-500">Sort:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'newest' | 'oldest' | 'name')}
className="bg-transparent text-white focus:outline-none cursor-pointer pr-1 text-xs font-medium"
aria-label="Sort applicants"
>
<option value="newest" className="bg-surface-container-high text-white">Newest First</option>
<option value="oldest" className="bg-surface-container-high text-white">Oldest First</option>
<option value="name" className="bg-surface-container-high text-white">Name</option>
</select>
</div>
</div>
</div>
{/* 4. MAIN CONTENT AREA */}
{loading ? (
<div className="rounded-2xl glass-card border border-brand-border/60 p-12 text-center text-slate-400 text-xs shadow-lg">
<span className="material-symbols-outlined animate-spin text-2xl text-brand-cyan mb-2 block">
progress_activity
</span>
Loading pending applications...
</div>
) : error ? (
<div className="p-8 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
<div className="flex items-center justify-center space-x-1.5 text-xs font-mono font-semibold text-rose-400">
<span className="material-symbols-outlined text-lg">error</span>
<span>Error</span>
</div>
<p className="text-xs text-slate-400">{error}</p>
<button
onClick={() => void loadApplications()}
className="px-4 py-2 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-brand-border/80 text-slate-200 text-xs font-medium transition-colors"
>
Try Again
</button>
</div>
) : applications.length === 0 ? (
/* Empty State 1: Default zero-pending queue */
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 flex items-center justify-center mx-auto text-slate-400">
<span className="material-symbols-outlined text-2xl">person_add_disabled</span>
</div>
<div>
<h3 className="text-sm font-semibold text-white font-mono">No Pending Applications</h3>
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
There are no membership requests waiting for review. New requests will appear here when riders apply.
</p>
</div>
</div>
) : filteredApplications.length === 0 ? (
/* Empty State 2: Filter zero-match */
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg">
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 flex items-center justify-center mx-auto text-amber-400">
<span className="material-symbols-outlined text-2xl">search_off</span>
</div>
<div>
<h3 className="text-sm font-semibold text-white font-mono">No Applications Match</h3>
<p className="text-xs text-slate-400 mt-1">
No pending applications match &quot;{searchQuery}&quot;.
</p>
<button
onClick={() => setSearchQuery('')}
className="text-xs text-brand-cyan hover:underline font-medium inline-flex items-center space-x-1 mt-2"
>
<span>Clear Search</span>
<span>→</span>
</button>
</div>
</div>
) : (
/* Two-Column Split View */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Dense Application Table */}
<div className="lg:col-span-7 xl:col-span-8 rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg flex flex-col">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-brand-border/60 bg-surface-container-high/60 text-[11px] font-mono text-slate-400 tracking-wider">
<th className="py-3.5 px-4 font-medium">Rider</th>
<th className="py-3.5 px-4 font-medium">Requested</th>
<th className="py-3.5 px-4 font-medium">Status</th>
<th className="py-3.5 px-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-brand-border/40 text-xs">
{filteredApplications.map((app) => {
const isSelected = selectedApplicant?.uid === app.uid;
const isMutating = mutatingUid === app.uid;
return (
<tr
key={app.uid}
onClick={() => setSelectedApplicantUid(app.uid)}
className={`cursor-pointer transition-colors ${
isSelected
? 'bg-brand-cyan/10 border-l-2 border-brand-cyan'
: 'hover:bg-surface-container-high/40'
}`}
>
<td className="py-3.5 px-4">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-xl bg-brand-cyan/15 border border-brand-cyan/40 flex items-center justify-center font-display font-bold text-xs text-brand-cyan shrink-0">
{getMonogram(app.displayName)}
</div>
<div className="min-w-0">
<div className="font-medium text-white flex items-center gap-1.5 truncate">
{app.displayName}
{isSelected && (
<span className="px-2 py-0.5 rounded-full text-[9px] font-mono bg-brand-cyan/20 text-brand-cyan border border-brand-cyan/40 tracking-wider shrink-0">
Inspecting
</span>
)}
</div>
{app.handle && (
<div className="text-[11px] font-mono text-slate-400 truncate">
{app.handle}
</div>
)}
</div>
</div>
</td>
<td className="py-3.5 px-4 font-mono text-[11px] text-slate-300 whitespace-nowrap">
{formatDate(app.requestedAt)}
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-mono font-semibold bg-amber-500/15 border border-amber-500/40 text-amber-400">
Pending
</span>
</td>
<td className="py-3.5 px-4 text-right whitespace-nowrap">
<div
className="flex items-center justify-end space-x-1.5"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setSelectedApplicantUid(app.uid)}
className="px-3 py-1 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-brand-border/80 text-slate-300 hover:text-white text-[11px] font-medium transition-colors"
>
Review
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setApproveModalApplicant(app)}
className="px-3 py-1 rounded-xl bg-brand-cyan text-obsidian-darkest hover:bg-cyan-300 text-[11px] font-bold shadow-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isMutating ? '...' : 'Approve'}
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setDeclineModalApplicant(app)}
className="px-3 py-1 rounded-xl bg-transparent hover:bg-rose-500/10 border border-brand-border/80 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 text-[11px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Decline
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination / Count Footer */}
<div className="p-3.5 border-t border-brand-border/60 bg-surface-container-low/40 flex items-center justify-between text-xs text-slate-400">
<span className="font-mono text-[11px]">
Showing {filteredApplications.length} of {applications.length} pending applications
</span>
</div>
</div>
{/* Right Column: Selected Application Inspector Drawer */}
{selectedApplicant && (
<div className="lg:col-span-5 xl:col-span-4 space-y-4">
<div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative">
{/* Top Cyan Accent Line */}
<div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" />
<div className="p-5 sm:p-6 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="w-11 h-11 rounded-xl bg-brand-cyan/15 border border-brand-cyan/40 flex items-center justify-center font-display font-bold text-base text-brand-cyan">
{getMonogram(selectedApplicant.displayName)}
</div>
<div>
<h3 className="font-headline font-bold text-base text-white">
{selectedApplicant.displayName}
</h3>
{selectedApplicant.handle && (
<div className="font-mono text-xs text-slate-400">
{selectedApplicant.handle}
</div>
)}
</div>
</div>
<div className="flex flex-col items-end space-y-1">
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-semibold bg-amber-500/15 border border-amber-500/40 text-amber-400">
PENDING
</span>
<span className="px-2 py-0.5 rounded-full text-[9px] font-mono text-slate-400 bg-surface-container-high border border-brand-border/60">
JOIN REQUEST
</span>
</div>
</div>
<div className="border-t border-brand-border/40" />
{/* Rider Spec Grid */}
<div className="space-y-2.5 text-xs">
<div className="flex justify-between items-center py-1 border-b border-brand-border/30">
<span className="text-slate-400">Requested</span>
<span className="font-mono text-white text-[11px]">
{formatDate(selectedApplicant.requestedAt)}
</span>
</div>
<div className="flex justify-between items-center py-1 border-b border-brand-border/30">
<span className="text-slate-400">Initial Club Rank</span>
<span className="font-mono text-white text-[11px]">Member (Default)</span>
</div>
<div className="flex justify-between items-center py-1 border-b border-brand-border/30">
<span className="text-slate-400">Administrative Role</span>
<span className="font-mono text-white text-[11px]">Member (Non-admin)</span>
</div>
</div>
{/* Mandatory Privacy Architecture Notice */}
<div className="p-3.5 rounded-xl bg-surface-container-low/80 border border-brand-cyan/30 space-y-1.5">
<div className="flex items-center space-x-1.5 text-[11px] font-mono font-semibold text-brand-cyan">
<span className="material-symbols-outlined text-[16px] shrink-0">shield</span>
<span>MANDATORY PRIVACY ARCHITECTURE</span>
</div>
<p className="text-[11px] text-slate-400 leading-normal">
Club administrators do not receive rider telemetry, private routes, or live GPS upon application. Club membership alone never grants access to rider location. Location sharing is strictly scoped to mutually joined active Group Rides.
</p>
</div>
{/* Architecture Note */}
<div className="p-3 rounded-xl bg-surface-container-high/60 border border-brand-border/60 text-[10px] text-slate-400 font-mono leading-tight">
ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant.
</div>
{/* Primary Actions */}
<div className="space-y-2 pt-1">
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setApproveModalApplicant(selectedApplicant)}
className="w-full h-10 rounded-xl bg-brand-cyan hover:bg-cyan-300 text-obsidian-darkest font-bold text-xs shadow-[0_0_15px_rgba(0,242,254,0.3)] transition-all flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">check</span>
<span>{mutatingUid === selectedApplicant.uid ? 'Processing...' : 'Approve Application'}</span>
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setDeclineModalApplicant(selectedApplicant)}
className="w-full h-10 rounded-xl bg-transparent hover:bg-rose-500/10 border border-brand-border/80 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 font-medium text-xs transition-colors flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">close</span>
<span>Decline Application</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* MODAL 1: APPROVE APPLICATION */}
{approveModalApplicant && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="rounded-2xl glass-card border border-brand-border/80 bg-surface-container-low/95 backdrop-blur-xl p-6 max-w-md w-full shadow-2xl relative">
<div className="flex items-center justify-between pb-3 mb-4 border-b border-brand-border/60">
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-brand-cyan" />
<span className="text-xs font-mono tracking-wider text-brand-cyan font-semibold">
Approval Confirmation
</span>
</div>
</div>
<h4 className="font-headline font-bold text-base text-white">Approve Application?</h4>
<p className="text-xs text-slate-300 mt-2 leading-relaxed">
Approve <span className="text-white font-semibold">{approveModalApplicant.displayName}</span>&apos;s request to join <span className="text-white font-semibold">{club.name}</span>? On approval, this rider becomes an active Club member.
</p>
<div className="mt-3.5 p-3.5 rounded-xl bg-surface-container-high border border-brand-border/60 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-400">Initial Club Rank:</span>
<span className="font-mono text-white">Member <span className="text-slate-500">(Default)</span></span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Administrative Role:</span>
<span className="font-mono text-white">Member <span className="text-slate-500">(Default Member, non-admin)</span></span>
</div>
</div>
<div className="mt-3 p-3 rounded-xl bg-surface-container-low/80 border border-brand-border/60 text-[10px] text-slate-400 font-mono leading-tight">
ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant.
</div>
<div className="mt-5 flex items-center justify-end space-x-2.5">
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setApproveModalApplicant(null)}
className="px-4 py-2 rounded-xl text-xs text-slate-400 hover:text-white border border-brand-border/80 hover:bg-surface-container-high transition-colors font-mono"
>
Cancel
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => void handleApprove(approveModalApplicant)}
className="px-4 py-2 rounded-xl text-xs font-bold text-obsidian-darkest bg-brand-cyan hover:bg-cyan-300 transition-all shadow-[0_0_12px_rgba(0,242,254,0.3)] disabled:opacity-50 cursor-pointer"
>
{mutatingUid === approveModalApplicant.uid ? 'Approving...' : 'Approve Member'}
</button>
</div>
</div>
</div>
)}
{/* MODAL 2: DECLINE APPLICATION */}
{declineModalApplicant && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="rounded-2xl glass-card border border-brand-border/80 bg-surface-container-low/95 backdrop-blur-xl p-6 max-w-md w-full shadow-2xl relative">
<div className="flex items-center justify-between pb-3 mb-4 border-b border-brand-border/60">
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-slate-400" />
<span className="text-xs font-mono tracking-wider text-slate-300">
Decline Application
</span>
</div>
</div>
<h4 className="font-headline font-bold text-base text-white">Decline Application?</h4>
<p className="text-xs text-slate-300 mt-2 leading-relaxed">
Decline <span className="text-white font-semibold">{declineModalApplicant.displayName}</span>&apos;s request to join this Club? This removes the pending request. The rider&apos;s MotorSignal account remains unaffected.
</p>
<div className="mt-5 flex items-center justify-end space-x-2.5">
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => setDeclineModalApplicant(null)}
className="px-4 py-2 rounded-xl text-xs text-slate-400 hover:text-white border border-brand-border/80 hover:bg-surface-container-high transition-colors font-mono"
>
Cancel
</button>
<button
type="button"
disabled={Boolean(mutatingUid)}
onClick={() => void handleDecline(declineModalApplicant)}
className="px-4 py-2 rounded-xl text-xs font-bold text-white bg-rose-500 hover:bg-rose-400 transition-all shadow-[0_0_12px_rgba(244,63,94,0.3)] disabled:opacity-50 cursor-pointer"
>
{mutatingUid === declineModalApplicant.uid ? 'Declining...' : 'Decline Application'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,240 @@
'use client';
import React, { useState } from 'react';
import {
Club,
ClubMetrics,
ClubUpcomingRide,
ClubMember,
ClubActivityItem,
UserPortalPermissions,
ClubRole,
} from './types';
import { ClubMasthead } from './club-masthead';
import { ExecutiveMetrics } from './metric-card';
import { AttentionSection } from './attention-card';
import { UpcomingRidesSection } from './club-ride-card';
import { RolesAndRanksSummary } from './roles-and-ranks-summary';
import { ClubActivityList } from './club-activity-list';
interface FounderOverviewProps {
club: Club;
metrics: ClubMetrics;
upcomingRides: ClubUpcomingRide[];
pendingApplications: ClubMember[];
activities: ClubActivityItem[];
permissions: UserPortalPermissions;
onRefresh?: () => void;
}
export function FounderOverview({
club,
metrics: initialMetrics,
upcomingRides: initialRides,
pendingApplications: initialApps,
activities,
permissions,
onRefresh,
}: FounderOverviewProps) {
const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps);
const [rides] = useState<ClubUpcomingRide[]>(initialRides);
const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics);
const [notification, setNotification] = useState<string | null>(null);
// Modal dialog state
const [activeModal, setActiveModal] = useState<'plan_ride' | 'invite_member' | 'settings' | 'announcement' | 'switch_club' | null>(null);
function showToast(message: string) {
setNotification(message);
setTimeout(() => {
setNotification((curr) => (curr === message ? null : curr));
}, 4000);
}
function handleApproveApplication(uid: string) {
if (!permissions.canApproveApplications) {
showToast('Administrative privileges are required to approve applications.');
return;
}
const target = pendingApps.find((a) => a.uid === uid);
setPendingApps((prev) => prev.filter((a) => a.uid !== uid));
setMetrics((prev) => ({
...prev,
members: prev.members + 1,
pendingApplications: Math.max(0, prev.pendingApplications - 1),
}));
showToast(`Approved ${target?.displayName ?? 'Applicant'}'s membership.`);
}
function handleReviewApplication(uid: string) {
if (uid === 'all') {
showToast('Navigating to Applications triage management queue.');
} else {
const target = pendingApps.find((a) => a.uid === uid);
showToast(`Reviewing applicant profile: ${target?.displayName ?? 'Rider'}`);
}
}
function handleAssignSweep(rideId: string) {
if (!permissions.canPlanRides) {
showToast('Road Captain or administrative role required to assign sweep positions.');
return;
}
showToast('Sweep assignment modal opened. Designate qualified Road Marshal.');
}
return (
<div className="flex flex-col space-y-6">
{/* Toast Notification */}
{notification && (
<div
role="status"
className="fixed bottom-6 right-6 z-50 rounded-lg bg-surface-container-high border border-outline-variant px-4 py-3 shadow-xl flex items-center gap-2.5 text-xs text-on-surface font-body animate-fadeIn"
>
<span className="material-symbols-outlined text-secondary text-[18px]">info</span>
<span>{notification}</span>
<button
onClick={() => setNotification(null)}
className="text-outline hover:text-on-surface ml-2"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
{/* Member Access Notice if current role is member */}
{!permissions.canManageSettings && (
<div className="p-3.5 rounded-lg bg-surface-container-high border border-outline-variant text-on-surface-variant flex items-center justify-between gap-3 text-xs font-body">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">visibility</span>
<span>
Viewing Founder Portal in <strong className="text-on-surface">Member View Mode</strong>. Administrative actions and mutations are restricted to Club Owner and Admins.
</span>
</div>
<span className="font-mono text-[11px] text-outline shrink-0">View-Only</span>
</div>
)}
{/* 1. CLUB IDENTITY MASTHEAD */}
<ClubMasthead
club={club}
permissions={permissions}
onPlanRide={() => setActiveModal('plan_ride')}
onInviteMember={() => setActiveModal('invite_member')}
onOpenSettings={() => setActiveModal('settings')}
/>
{/* 2. EXECUTIVE METRICS */}
<ExecutiveMetrics metrics={metrics} />
{/* 3. ATTENTION REQUIRED */}
<AttentionSection
pendingApplications={pendingApps}
upcomingRides={rides}
permissions={permissions}
onApproveApplication={handleApproveApplication}
onReviewApplication={handleReviewApplication}
onAssignSweep={handleAssignSweep}
onMarkChecked={() => showToast('All pending review alerts acknowledged.')}
/>
{/* 4. UPCOMING CLUB RIDES */}
<UpcomingRidesSection
rides={rides}
permissions={permissions}
onPlanRide={() => setActiveModal('plan_ride')}
onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)}
onViewRoster={(rideId) => showToast(`Displaying confirmed rider roster for Ride ID: ${rideId}`)}
onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)}
/>
{/* 5. ROLES & RANKS */}
<RolesAndRanksSummary
ownerName={club.founderName || 'Club Founder'}
memberCount={club.memberCount}
/>
{/* 6. CLUB ACTIVITY */}
<ClubActivityList activities={activities} />
{/* Modal Dialogs */}
{activeModal && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
>
<div className="w-full max-w-md rounded-xl bg-surface-container-lowest border border-outline-variant p-6 shadow-2xl space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-outline-variant">
<h3 className="font-headline text-base font-semibold text-on-surface">
{activeModal === 'plan_ride' && 'Plan Club Ride'}
{activeModal === 'invite_member' && 'Invite New Member'}
{activeModal === 'settings' && 'Club Administration Settings'}
{activeModal === 'announcement' && 'Post Club Announcement'}
{activeModal === 'switch_club' && 'Switch Club Roster'}
</h3>
<button
onClick={() => setActiveModal(null)}
className="text-outline hover:text-on-surface p-1"
aria-label="Close dialog"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
<div className="font-body text-xs text-on-surface-variant space-y-3">
{activeModal === 'plan_ride' && (
<p>
Create a new scheduled Group Ride associated with <strong>{club.name}</strong>. Designated routes and checkpoints will sync with ride participants.
</p>
)}
{activeModal === 'invite_member' && (
<p>
Send an official membership invitation to a verified rider handle. Once accepted, they will join the club roster.
</p>
)}
{activeModal === 'settings' && (
<p>
Club metadata, visibility policy, admission policy, and regional preferences are administered here.
</p>
)}
{activeModal === 'announcement' && (
<p>
Broadcast an administrative update to all active members of {club.name}.
</p>
)}
{activeModal === 'switch_club' && (
<p>
Select another registered Motorcycle Club from your active memberships.
</p>
)}
<div className="p-3 rounded bg-surface-container border border-outline-variant text-outline font-mono text-[11px]">
FEATURE CONSOLE // STAGE FP-1 PROD FOUNDATION
</div>
</div>
<div className="pt-3 border-t border-outline-variant/50 flex items-center justify-end gap-2">
<button
onClick={() => setActiveModal(null)}
className="h-9 px-4 rounded-xl bg-surface-container text-slate-200 border border-outline-variant/60 font-body text-xs hover:bg-surface-container-high transition-colors cursor-pointer"
>
Close
</button>
<button
onClick={() => {
showToast('Action acknowledged.');
setActiveModal(null);
}}
className="h-9 px-4 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all cursor-pointer"
>
Confirm
</button>
</div>
</div>
</div>
)}
</div>
);
}

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-brand-bg telemetry-grid-bg font-body text-sm text-slate-200 antialiased selection:bg-primary-container selection:text-on-primary-container">
{/* 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-24 pb-16 px-4 sm:px-6 lg:px-8 w-full max-w-[1440px] mx-auto flex-1">
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,556 @@
'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-brand-border/60 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-brand-cyan tracking-widest bg-brand-cyan/10 px-2.5 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
Precision, Not Aggression
</span>
<span className="font-mono text-xs text-slate-400 tracking-wider">
// {homeRegion ? `${homeRegion} Zone` : 'Regional Squadron'}
</span>
</div>
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-white mt-2 tracking-tight">
Club Profile
</h1>
<p className="font-body text-sm text-slate-400 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="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Club Name</span>
<span className="w-1.5 h-1.5 bg-brand-cyan rounded-full" />
</div>
<div className="mt-3">
<div className="font-headline text-base font-bold text-white truncate">{name || club.name}</div>
<div className="font-mono text-[11px] text-brand-cyan mt-1">3-50 chars valid</div>
</div>
</div>
{/* Card 2: Home Region */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Home Region</span>
<span className="material-symbols-outlined text-slate-400 text-base">location_on</span>
</div>
<div className="mt-3">
<div className="font-headline text-base font-bold text-white truncate">
{homeRegion || 'Not specified'}
</div>
<div className="font-mono text-[11px] text-slate-400 mt-1">Home Region</div>
</div>
</div>
{/* Card 3: Motorcycle Focus */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Motorcycle Focus</span>
<span className="material-symbols-outlined text-slate-400 text-base">sports_motorsports</span>
</div>
<div className="mt-3">
<div className="font-headline text-base font-bold text-white truncate">
{motorcycleFocus || 'General'}
</div>
<div className="font-mono text-[11px] text-slate-400 mt-1">Motorcycle Focus</div>
</div>
</div>
{/* Card 4: Discovery Presence */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Discovery Presence</span>
<span
className={`w-2 h-2 rounded-full ${
club.visibility === 'public' ? 'bg-brand-cyan' : club.visibility === 'unlisted' ? 'bg-amber-400' : 'bg-slate-500'
}`}
/>
</div>
<div className="mt-3">
<div
className={`font-headline text-base font-bold capitalize ${
club.visibility === 'public' ? 'text-brand-cyan' : club.visibility === 'unlisted' ? 'text-amber-400' : 'text-white'
}`}
>
{club.visibility}
</div>
<div className="font-mono text-[11px] text-slate-400 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="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">badge</span>
<h2 className="font-headline text-base font-bold text-white">
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-xs text-slate-400 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-container-low/90 border border-brand-border/80 focus:border-brand-cyan rounded-xl px-3.5 py-2.5 text-sm font-body text-white outline-none transition-colors"
/>
<span
className={`absolute right-3.5 top-3 font-mono text-[11px] font-semibold ${
isNameValid ? 'text-brand-cyan' : 'text-error'
}`}
>
{isNameValid ? 'Valid' : `${trimmedName.length}/50`}
</span>
</div>
<p className="font-body text-xs text-slate-400">
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-xs text-slate-400 font-medium">
Club Handle / Identifier
</label>
<span className="font-mono text-[10px] px-2 py-0.5 bg-surface-container text-slate-400 border border-brand-border/60 rounded-lg">
Immutable
</span>
</div>
<div className="relative flex items-center">
<span className="material-symbols-outlined absolute left-3.5 text-slate-400 text-base">lock</span>
<input
type="text"
value={`@${club.slug}`}
readOnly
disabled
className="w-full bg-surface-container-lowest/80 border border-brand-border/60 rounded-xl pl-10 pr-3.5 py-2.5 text-xs font-mono text-slate-400 cursor-not-allowed opacity-90"
/>
</div>
<p className="font-body text-xs text-slate-400">
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-xs text-slate-400 block font-medium">
Club Description
</label>
<span className="font-mono text-[11px] text-slate-400">
{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-container-low/90 border border-brand-border/80 focus:border-brand-cyan rounded-xl p-3.5 text-sm font-body text-white outline-none transition-colors leading-relaxed"
/>
<p className="font-body text-xs text-slate-400">
Displayed on your public Club profile.
</p>
</div>
</div>
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">explore</span>
<h2 className="font-headline text-base font-bold text-white">
Section 2: Regional &amp; Motorcycle Focus
</h2>
</div>
<span className="font-mono text-xs text-slate-400">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-xs text-slate-400 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-container-low/90 border border-brand-border/80 focus:border-brand-cyan rounded-xl px-3.5 py-2.5 text-sm font-body text-white outline-none transition-colors"
/>
<p className="font-body text-xs text-slate-400">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-xs text-slate-400 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-container-low/90 border border-brand-border/80 focus:border-brand-cyan rounded-xl px-3.5 py-2.5 text-sm font-body text-white outline-none transition-colors"
/>
<p className="font-body text-xs text-slate-400">
Main riding style or motorcycle focus for your Club.
</p>
</div>
</div>
</div>
{/* SECTION 3: LOGO & BANNER (Presentation Only) */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">palette</span>
<h2 className="font-headline text-base font-bold text-white">
Section 3: Logo &amp; Banner
</h2>
</div>
<span className="font-mono text-xs text-slate-400">Visual Assets</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Logo Presentation Card */}
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-xs text-slate-400">
<span>Club Emblem</span>
</div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl 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-xl border-2 border-brand-cyan bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative">
<span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span>
<span className="font-headline text-brand-cyan 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-slate-400">
<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-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-xs text-slate-400">
<span>Banner Presentation</span>
</div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl 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(#00f2fe_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
<div className="flex items-center gap-2 font-mono text-xs text-slate-400 z-10">
<span className="material-symbols-outlined text-base text-brand-cyan">image</span>
<span className="tracking-wider font-semibold text-white">{club.name}</span>
</div>
</div>
)}
</div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400">
<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="rounded-2xl glass-card border border-brand-border/60 p-5 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl">
<div className="flex items-center gap-2 font-body text-xs text-slate-300">
<span className="material-symbols-outlined text-brand-cyan 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-6 py-2.5 rounded-xl font-headline text-xs font-bold tracking-wider transition-all duration-150 ${
canSave && (isDirty || saveStatus === 'error')
? 'bg-primary-container hover:brightness-110 text-on-primary-container shadow-[0_0_16px_rgba(0,242,254,0.3)] cursor-pointer'
: 'bg-surface-container-high text-slate-500 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="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-4 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-base">visibility</span>
<span data-section="DIRECTORY PREVIEW" className="font-headline text-sm text-white font-bold">
Directory Preview
</span>
</div>
<span data-badge="LIVE SIMULATION" className="font-mono text-[10px] bg-brand-cyan/10 text-brand-cyan px-2.5 py-0.5 rounded-full border border-brand-cyan/30 font-semibold">
Live Simulation
</span>
</div>
{/* Embedded Public Card Mockup (IDENTITY ONLY, ZERO METRICS) */}
<div className="rounded-xl bg-surface-container-low border border-brand-border/60 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(#00f2fe_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-surface-container-low" />
</div>
<div className="p-4 pt-0 relative">
{/* Emblem overlapping */}
<div className="-mt-8 w-14 h-14 rounded-xl bg-surface-container-high border-2 border-brand-cyan flex flex-col items-center justify-center text-brand-cyan 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-white font-bold truncate">
{name || club.name}
</h3>
</div>
<div className="font-mono text-[11px] text-brand-cyan mt-0.5">@{club.slug}</div>
<div className="mt-2 inline-flex items-center gap-1.5 font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/20">
<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-slate-300 line-clamp-2 leading-relaxed">
{description || 'No club description provided yet.'}
</p>
</div>
</div>
<p className="font-mono text-[10px] text-slate-400 text-center">
Rendered in standard public directory feed (1:1 responsive layout)
</p>
</div>
{/* CARD B: ACCESS & DISCOVERY BOUNDARY */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-3 shadow-xl">
<div className="flex items-center gap-2 text-white border-b border-brand-border/60 pb-3">
<span className="material-symbols-outlined text-brand-cyan text-base">admin_panel_settings</span>
<h3 className="font-headline text-sm font-bold">Access Controls</h3>
</div>
<p className="font-body text-xs text-slate-300 leading-relaxed">
Access settings (Visibility &amp; Admission Policy) are managed separately under Club Settings.
</p>
<div className="pt-1 flex items-center">
<Link href={`/clubs/${club.slug || club.id}/manage/settings`} className="inline-flex items-center gap-1.5 text-brand-cyan hover:underline font-mono text-xs font-semibold">
<span>Manage Access Settings</span>
<span className="material-symbols-outlined text-sm">arrow_forward</span>
</Link>
</div>
</div>
{/* CARD C: PRIVACY CONTRACT */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-3 shadow-xl">
<div className="flex items-center gap-2 text-brand-cyan">
<span className="material-symbols-outlined text-base">lock</span>
<span data-section="PRIVACY CONTRACT" className="font-mono text-xs font-bold tracking-wider uppercase">Privacy Contract</span>
</div>
<blockquote className="font-body text-xs italic text-slate-200 border-l-2 border-brand-cyan pl-3 py-1 bg-surface-container-lowest/60 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-slate-400">
Telemetry and member activity are strictly isolated.
</p>
</div>
</div>
</form>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,283 @@
'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}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${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 isSettings = item.id === 'settings';
const isApplications = item.id === 'applications';
const isSubRoute = isProfile || isSettings || isApplications;
const content = (
<>
<div className="flex items-center gap-2.5">
<span
className={`material-symbols-outlined text-[18px] ${
isActive && isSubRoute ? 'text-brand-cyan' : ''
}`}
style={isActive && isSubRoute ? { fontVariationSettings: "'FILL' 1" } : undefined}
>
{item.icon}
</span>
<span className={isActive && isSubRoute ? 'text-brand-cyan 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-primary-container/20 text-brand-cyan font-medium border border-brand-cyan/30'
: item.badgeType === 'error'
? 'bg-error-container text-on-error-container font-semibold border border-error/30'
: item.badgeType === 'secondary'
? 'bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/30 font-semibold'
: 'bg-surface-container-high text-on-surface-variant border border-outline-variant'
}`}
>
{item.badge}
</span>
)}
{isActive && isSubRoute && !item.badge && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
)}
</>
);
const baseClasses = `w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors text-xs ${
isActive
? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container 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-brand-cyan tracking-normal 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-normal font-medium">
Current Club
</span>
<span className="flex items-center gap-1.5 font-mono text-[10px] text-brand-cyan">
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
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-brand-border/60 bg-surface-container-low">
<div className="flex items-center justify-between p-2 rounded-xl bg-surface-container border border-brand-border/60">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-brand-cardAlt to-slate-800 border border-brand-border/80 flex items-center justify-center text-brand-cyan shrink-0">
<span className="material-symbols-outlined 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-brand-cyan 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,292 @@
'use client';
import React, { useState, useRef, useEffect } 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) {
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setUserMenuOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setUserMenuOpen(false);
}
}
if (userMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [userMenuOpen]);
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-brand-border/60 z-40 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-[1440px] mx-auto h-full flex items-center justify-between gap-3 sm: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-lg 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-[180px] md:max-w-none hover:text-brand-cyan transition-colors"
>
{club.name}
</Link>
{activeNav === 'profile' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-brand-cyan font-semibold">Club Profile</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-brand-cyan font-semibold">Settings</span>
</>
)}
{activeNav === 'applications' && (
<>
<span className="text-outline-variant">/</span>
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold">Applications</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-lg bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs hover:bg-surface-container-high transition-colors"
>
<span className="material-symbols-outlined text-[15px] text-brand-cyan">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-xs mx-2 lg:mx-4 min-w-0 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-lg bg-surface-container-low border border-brand-border/60 text-on-surface placeholder:text-outline/70 font-body text-xs focus:outline-none focus:border-brand-cyan 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-lg bg-surface-container-high border border-brand-border/60 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 capitalize font-semibold transition-colors ${
permissions.role === r
? 'bg-primary-container text-on-primary-container font-bold'
: 'hover:text-on-surface'
}`}
>
{r}
</button>
))}
</div>
) : (
<div
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-container border border-brand-border/60 text-[11px] font-mono text-outline capitalize font-medium"
title="Authoritative software role"
>
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
<span className="text-on-surface font-semibold">{permissions.role}</span>
</div>
)}
{/* Post Announcement (Owner / Admin only) */}
{permissions.canManageSettings && (
<button
onClick={onPostAnnouncement}
className="hidden xl:flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors"
>
<span className="material-symbols-outlined text-[15px] text-brand-cyan">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.5 py-1.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all shrink-0"
>
<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 shrink-0">
<button
className="w-8 h-8 rounded-xl bg-surface-container border border-brand-border/60 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>
{/* Rider Profile Menu Dropdown */}
<div className="relative shrink-0 ml-0.5" ref={userMenuRef}>
<button
type="button"
onClick={() => setUserMenuOpen(!userMenuOpen)}
className={`w-8 h-8 rounded-xl bg-surface-container-low hover:bg-surface-container-high border transition-all flex items-center justify-center cursor-pointer ${
userMenuOpen
? 'border-primary-container ring-2 ring-primary-container/20'
: 'border-brand-border/60 hover:border-brand-cyan/50'
}`}
title="Rider Menu"
aria-label="Open rider menu"
aria-expanded={userMenuOpen}
aria-haspopup="menu"
>
<span className="material-symbols-outlined text-primary-container text-[18px]">
person
</span>
</button>
{userMenuOpen && (
<div
role="menu"
aria-orientation="vertical"
className="absolute right-0 mt-2 w-60 rounded-2xl bg-surface-container-lowest/95 backdrop-blur-2xl border border-brand-border/80 shadow-2xl p-2 z-50 animate-in fade-in slide-in-from-top-2 flex flex-col gap-1"
>
<div className="px-3 py-2 border-b border-brand-border/40 mb-1">
<span className="font-headline text-xs font-semibold text-white block">
{club.founderName || 'Rider Account'}
</span>
<span className="font-mono text-[10px] text-brand-cyan">
{permissions.role === 'owner' ? 'Club Owner' : permissions.role === 'admin' ? 'Club Admin' : 'Club Member'}
</span>
</div>
<Link
href="/account"
onClick={() => setUserMenuOpen(false)}
role="menuitem"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-slate-200 hover:text-brand-cyan hover:bg-surface-container transition-colors text-xs font-semibold"
>
<span className="material-symbols-outlined text-[18px] text-brand-cyan">
person
</span>
<span>Rider Account</span>
</Link>
<Link
href="/clubs/manage"
onClick={() => setUserMenuOpen(false)}
role="menuitem"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-slate-200 hover:text-brand-cyan hover:bg-surface-container transition-colors text-xs font-semibold"
>
<span className="material-symbols-outlined text-[18px] text-brand-cyan">
shield
</span>
<span>Founder Portal</span>
</Link>
{(permissions.role === 'owner' || permissions.role === 'admin') && (
<Link
href="/admin"
onClick={() => setUserMenuOpen(false)}
role="menuitem"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-slate-200 hover:text-brand-cyan hover:bg-surface-container transition-colors text-xs font-semibold"
>
<span className="material-symbols-outlined text-[18px] text-brand-cyan">
admin_panel_settings
</span>
<span>Operations Admin</span>
</Link>
)}
<div className="border-t border-brand-border/40 my-1" />
<button
type="button"
role="menuitem"
onClick={async () => {
setUserMenuOpen(false);
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch {}
try {
const { firebaseAuth } = await import('../../lib/firebase-client');
await firebaseAuth.signOut();
} catch {}
window.location.assign('/login');
}}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-error/80 hover:text-error hover:bg-error-container/10 transition-colors text-xs font-semibold text-left cursor-pointer"
>
<span className="material-symbols-outlined text-[18px]">logout</span>
<span>Sign Out</span>
</button>
</div>
)}
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,198 @@
'use client';
import React from 'react';
import { ClubMetrics } from './types';
interface MetricCardProps {
label: string;
value: string | number;
unit?: string;
badge?: React.ReactNode;
subtextLeft?: React.ReactNode;
subtextRight?: React.ReactNode;
disclaimer?: string;
sparklineVariant?: 'secondary' | 'error' | 'primary';
showSparkline?: boolean;
isUnavailable?: boolean;
}
export function MetricCard({
label,
value,
unit,
badge,
subtextLeft,
subtextRight,
disclaimer,
sparklineVariant = 'secondary',
showSparkline = false,
isUnavailable = false,
}: MetricCardProps) {
const sparklineColors = {
secondary: { stroke: 'text-secondary', fill: '#38bdf8', path: 'M0 24 L25 18 L50 20 L75 10 L100 4' },
error: { stroke: 'text-error', fill: '#f87171', path: 'M0 15 L25 18 L50 10 L75 22 L100 8' },
primary: { stroke: 'text-primary', fill: '#0088ff', path: 'M0 26 L30 20 L60 14 L100 8' },
};
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
return (
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
{/* Top Header Row */}
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span>
{badge}
</div>
{/* Primary Numerical Value & Sparkline */}
<div className="my-3 flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-headline text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
{isUnavailable ? '--' : value}
</span>
{unit && !isUnavailable && (
<span className="font-mono text-xs text-brand-cyan font-bold">{unit}</span>
)}
</div>
{showSparkline && !isUnavailable && (
<div className="w-20 h-6 shrink-0" aria-hidden="true">
<svg className={`w-full h-full ${selectedSpark.stroke}`} fill="none" viewBox="0 0 100 30">
<path
d={selectedSpark.path}
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
/>
<path
d={`${selectedSpark.path} L100 30 L0 30 Z`}
fill="currentColor"
fillOpacity="0.08"
/>
</svg>
</div>
)}
</div>
{/* Footer Subtext */}
<div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 font-mono text-xs text-slate-400">
<div className="truncate">{subtextLeft}</div>
<div className="text-slate-400 truncate sm:text-right">{subtextRight}</div>
</div>
{disclaimer && (
<span className="text-[10px] font-mono text-slate-500 truncate" title={disclaimer}>
{disclaimer}
</span>
)}
</div>
</div>
);
}
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
return (
<section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-5">
{/* 1. MEMBERS */}
<MetricCard
label="Members"
value={metrics.members.toLocaleString()}
showSparkline={false}
badge={
metrics.membersActivePercent != null ? (
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30">
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
{metrics.membersActivePercent}% ACTIVE
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ROSTER</span>
)
}
subtextLeft={
metrics.membersDeltaMonth != null ? (
<span className="text-brand-cyan font-medium">
+{metrics.membersDeltaMonth} this month
</span>
) : (
<span>Total Roster</span>
)
}
subtextRight={
metrics.roadMarshalsCount != null ? (
<span>{metrics.roadMarshalsCount} Road Marshals</span>
) : (
<span>Active members</span>
)
}
/>
{/* 2. PENDING APPLICATIONS */}
<MetricCard
label="Pending Applications"
value={metrics.pendingApplications}
showSparkline={false}
badge={
metrics.pendingApplications > 0 ? (
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30">
REQUIRES REVIEW
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ALL CLEARED</span>
)
}
subtextLeft={
metrics.pendingApplications > 0 ? (
<span className="text-error font-medium">Requires review</span>
) : (
<span>No pending</span>
)
}
subtextRight={<span>Awaiting founder review</span>}
/>
{/* 3. UPCOMING RIDES */}
<MetricCard
label="Upcoming Rides"
value={metrics.upcomingRidesCount}
showSparkline={false}
badge={
<span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
{metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'}
</span>
}
subtextLeft={
<span className="text-on-surface font-medium">
{metrics.confirmedRidersTotal} riders confirmed
</span>
}
subtextRight={<span>Next: {metrics.nextRideDate ?? 'None'}</span>}
/>
{/* 4. CLUB MILES THIS MONTH */}
<MetricCard
label="Club Miles (This Month)"
value={metrics.clubMilesThisMonth ? metrics.clubMilesThisMonth.toLocaleString() : '--'}
unit="MI"
showSparkline={false}
isUnavailable={!metrics.clubMilesAvailable}
badge={
metrics.clubMilesAvailable ? (
<span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
Completed Club Rides only
</span>
) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">UNAVAILABLE</span>
)
}
subtextLeft={
<span className="text-slate-300 font-medium">
{metrics.completedClubRidesCount} completed Club Rides
</span>
}
subtextRight={<span className="text-slate-400">Eligible rides only</span>}
disclaimer="Calculated from eligible completed Club Rides. Private rider mileage is not automatically included."
/>
</section>
);
}

View File

@ -0,0 +1,171 @@
'use client';
import React from 'react';
interface RolesAndRanksProps {
ownerName?: string;
adminCount?: number;
memberCount?: number;
}
export function RolesAndRanksSummary({
ownerName = 'Club Founder',
adminCount,
memberCount,
}: RolesAndRanksProps) {
return (
<section className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-lg">
{/* Section Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h2 className="font-headline text-lg sm:text-xl font-bold text-white tracking-tight">
Roles &amp; Ranks
</h2>
<p className="font-body text-xs sm:text-sm text-slate-400 mt-0.5">
Administrative roles determine software permissions. Club ranks are organizational titles and do not grant additional access.
</p>
</div>
<span className="px-2.5 py-0.5 rounded-full bg-brand-cyan/10 border border-brand-cyan/30 text-brand-cyan font-mono text-[11px] font-semibold tracking-normal self-start sm:self-center">
RBAC v2.4
</span>
</div>
{/* Split Structure Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
{/* Left: Administrative Roles (Software Permissions) */}
<div className="rounded-2xl bg-surface-container/60 border border-brand-border/60 p-5 space-y-4">
<div className="flex items-center justify-between pb-2.5 border-b border-brand-border/60">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-[18px]">
verified_user
</span>
<span data-section="ADMINISTRATIVE ROLES (Software Permissions)" className="font-headline text-xs font-bold text-white uppercase tracking-wider">
Administrative Roles (Software Permissions)
</span>
</div>
<span className="font-mono text-[10px] text-slate-400">3 ROLES</span>
</div>
<div className="space-y-2.5">
{/* Owner */}
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-3 flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-amber-400/15 text-amber-400 font-mono text-[10px] font-bold border border-amber-400/40">
OWNER
</span>
<span className="font-headline text-xs font-semibold text-white truncate">
{ownerName}
</span>
</div>
<div className="font-body text-[11px] text-slate-400 mt-1">
Full software permissions, billing, ownership transfer.
</div>
</div>
<span className="material-symbols-outlined text-amber-400 text-[20px]">
admin_panel_settings
</span>
</div>
{/* Admin */}
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-3 flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-brand-cyan/15 text-brand-cyan font-mono text-[10px] font-bold border border-brand-cyan/40">
ADMIN
</span>
<span className="font-headline text-xs font-semibold text-white">
{adminCount !== undefined ? `${adminCount} Assigned` : 'Admin Roster'}
</span>
</div>
<div className="font-body text-[11px] text-slate-400 mt-1">
Member administration, applications triage, ride creation.
</div>
</div>
<span className="material-symbols-outlined text-brand-cyan text-[20px]">
shield
</span>
</div>
{/* Member */}
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-3 flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-surface-container border border-brand-border/80 text-slate-300 font-mono text-[10px] font-semibold">
MEMBER
</span>
<span className="font-headline text-xs font-semibold text-white">
{memberCount !== undefined ? `${memberCount} Assigned` : 'Standard Access'}
</span>
</div>
<div className="font-body text-[11px] text-slate-400 mt-1">
Standard authenticated club access, participation.
</div>
</div>
<span className="material-symbols-outlined text-slate-400 text-[20px]">
group
</span>
</div>
</div>
</div>
{/* Right: Club Ranks (Organizational Titles) */}
<div className="rounded-2xl bg-surface-container/60 border border-brand-border/60 p-5 space-y-4">
<div className="flex items-center justify-between pb-2.5 border-b border-brand-border/60">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-[18px]">
military_tech
</span>
<span data-section="CLUB RANKS (ORGANIZATIONAL TITLES)" className="font-headline text-xs font-bold text-white uppercase tracking-wider">
Club Ranks (Organizational Titles)
</span>
</div>
<span className="font-mono text-[10px] text-slate-400">Titles</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-2.5 flex items-center justify-between">
<span className="font-body text-xs font-medium text-white">President</span>
<span className="font-mono text-[11px] text-slate-400">Title Only</span>
</div>
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-2.5 flex items-center justify-between">
<span className="font-body text-xs font-medium text-white">Vice President</span>
<span className="font-mono text-[11px] text-slate-400">Title Only</span>
</div>
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-2.5 flex items-center justify-between">
<span className="font-body text-xs font-medium text-white">Road Captain</span>
<span className="font-mono text-[11px] text-brand-cyan font-semibold">Operational</span>
</div>
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-2.5 flex items-center justify-between">
<span className="font-body text-xs font-medium text-white">Treasurer</span>
<span className="font-mono text-[11px] text-slate-400">Title Only</span>
</div>
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-2.5 flex items-center justify-between">
<span className="font-body text-xs font-medium text-white">Officer</span>
<span className="font-mono text-[11px] text-slate-400">Title Only</span>
</div>
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-2.5 flex items-center justify-between">
<span className="font-body text-xs font-medium text-white">Full Member</span>
<span className="font-mono text-[11px] text-slate-400">Title Only</span>
</div>
<div className="rounded-xl bg-surface-container-high/80 border border-brand-border/60 p-2.5 flex items-center justify-between sm:col-span-2">
<span className="font-body text-xs font-medium text-white">Prospect</span>
<span className="font-mono text-[11px] text-slate-400">Title Only</span>
</div>
</div>
</div>
</div>
{/* Bottom RBAC Note verbatim */}
<div className="p-3.5 rounded-xl bg-surface-container/60 border border-brand-border/60 text-slate-300 flex items-start gap-2.5">
<span className="material-symbols-outlined text-[18px] text-brand-cyan shrink-0 mt-0.5">
info
</span>
<div className="font-body text-xs">
<strong className="text-white font-medium">Architecture Note:</strong>{' '}
Administrative roles determine software permissions. Club ranks are organizational titles and do not grant additional access.
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,51 @@
'use client';
import React from 'react';
interface SafeErrorStateProps {
title?: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
onRetry?: () => void;
}
export function SafeErrorState({
title = 'Notice',
message = "Club information couldn't be loaded.",
actionLabel,
onAction,
onRetry,
}: SafeErrorStateProps) {
return (
<div
role="alert"
className="rounded-2xl glass-card border border-brand-border/60 p-8 sm:p-10 max-w-lg mx-auto my-12 text-center shadow-2xl"
>
<div className="w-14 h-14 rounded-2xl bg-error-container text-on-error-container flex items-center justify-center mx-auto mb-4 border border-error/40 shadow-lg">
<span className="material-symbols-outlined text-[28px]">shield_with_heart</span>
</div>
<h3 className="font-headline text-2xl font-bold text-white mb-2">{title}</h3>
<p className="font-body text-sm text-slate-400 mb-6 leading-relaxed">{message}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{onRetry && (
<button
onClick={onRetry}
className="h-10 px-5 rounded-xl bg-surface-container border border-outline-variant/60 text-slate-200 hover:text-white hover:bg-surface-container-high transition-colors font-body text-xs sm:text-sm font-medium inline-flex items-center gap-2 cursor-pointer"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
<span>Try Again</span>
</button>
)}
{onAction && actionLabel && (
<button
onClick={onAction}
className="h-10 px-5 rounded-xl bg-primary-container text-on-primary-container hover:brightness-110 shadow-[0_0_16px_rgba(0,242,254,0.3)] transition-all font-body text-xs sm:text-sm font-bold inline-flex items-center gap-2 cursor-pointer"
>
<span>{actionLabel}</span>
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
'use client';
import React from 'react';
export function SkeletonBlock({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded bg-surface-container-high/60 ${className}`} />;
}
export function OverviewSkeleton() {
return (
<div className="flex flex-col space-y-6" aria-label="Loading Founder Portal data" role="status">
{/* Masthead Skeleton */}
<div className="rounded-lg bg-surface-container-lowest border border-outline-variant p-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="flex items-center gap-5">
<SkeletonBlock className="w-16 h-16 rounded" />
<div className="space-y-2">
<SkeletonBlock className="w-64 h-7" />
<SkeletonBlock className="w-48 h-4" />
<SkeletonBlock className="w-80 h-3 mt-1" />
</div>
</div>
<div className="flex items-center gap-2">
<SkeletonBlock className="w-28 h-8 rounded" />
<SkeletonBlock className="w-24 h-8 rounded" />
</div>
</div>
</div>
{/* 4 Metric Cards Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-lg bg-surface-container-lowest border border-outline-variant p-4 h-32 flex flex-col justify-between">
<div className="flex justify-between">
<SkeletonBlock className="w-24 h-3" />
<SkeletonBlock className="w-16 h-3" />
</div>
<SkeletonBlock className="w-20 h-8" />
<div className="flex justify-between">
<SkeletonBlock className="w-24 h-3" />
<SkeletonBlock className="w-20 h-3" />
</div>
</div>
))}
</div>
{/* Attention Required Skeleton */}
<div className="rounded-lg bg-surface-container-lowest border border-outline-variant p-5 space-y-4">
<div className="flex justify-between">
<SkeletonBlock className="w-40 h-4" />
<SkeletonBlock className="w-28 h-4" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded bg-surface-container border border-outline-variant p-4 h-48 flex flex-col justify-between">
<SkeletonBlock className="w-32 h-4 mb-3" />
<SkeletonBlock className="w-full h-20" />
<SkeletonBlock className="w-24 h-4 self-end" />
</div>
))}
</div>
</div>
{/* Upcoming Rides Skeleton */}
<div className="space-y-4">
<div className="flex justify-between">
<SkeletonBlock className="w-44 h-5" />
<SkeletonBlock className="w-28 h-4" />
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{[1, 2].map((i) => (
<div key={i} className="rounded-lg bg-surface-container-lowest border border-outline-variant p-5 h-64 flex flex-col justify-between">
<SkeletonBlock className="w-48 h-4" />
<SkeletonBlock className="w-full h-12" />
<SkeletonBlock className="w-full h-16" />
<div className="flex justify-between pt-3 border-t border-outline-variant/60">
<SkeletonBlock className="w-36 h-8" />
<SkeletonBlock className="w-24 h-8" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,147 @@
export type ClubVisibility = 'public' | 'unlisted' | 'private';
export type ClubAdmissionPolicy = 'open' | 'request' | 'inviteOnly';
export type ClubRole = 'owner' | 'admin' | 'member';
export type ClubMemberStatus = 'invited' | 'requested' | 'active' | 'declined' | 'left' | 'removed';
export interface Club {
id: string;
name: string;
slug: string;
description: string;
visibility: ClubVisibility;
admissionPolicy: ClubAdmissionPolicy;
/**
* Identifies ownership identity only.
* Does NOT directly provide founder display name or rider handle.
* Canonical profile resolution: /public_profiles/{ownerUid}.
* Never persist a redundant 'founderName' field on /clubs/{clubId}.
*/
ownerUid: string;
memberCount: number;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
logoUrl?: string | null;
bannerUrl?: string | null;
homeRegion?: string | null;
motorcycleFocus?: string | null;
/**
* Transient presentation field resolved from /public_profiles/{ownerUid}.displayName.
* Not a persisted field on /clubs/{clubId}. Conservative fallback: 'Club Founder' or 'You'.
*/
founderName?: string | null;
/**
* Transient presentation field resolved from /public_profiles/{ownerUid}.username.
* Not a persisted field on /clubs/{clubId}. Conservative fallback: undefined.
*/
founderHandle?: string | null;
founderRank?: string | null;
foundedYear?: string | number | null;
}
export interface ClubMember {
uid: string;
clubId: string;
status: ClubMemberStatus;
role: ClubRole;
displayRank?: string | null;
joinedAt?: string | null;
requestedAt?: string | null;
invitedByUid?: string | null;
updatedAt: string;
removedAt?: string | null;
// Visual presentation fields
displayName?: string | null;
handle?: string | null;
avatarUrl?: string | null;
motorcycle?: string | null;
location?: string | null;
note?: string | null;
}
/**
* Club-affiliated upcoming ride model.
*
* CANONICAL AFFILIATION CONTRACT:
* - Authoritative link: GroupRide.clubId == club.id
* - Affiliation is NEVER inferred from accepted rosters, participant lists,
* organizer membership in a Club, accessUids, or live presence.
* - Hierarchy: Club (/clubs/{clubId}) -> GroupRide (/group_rides/{groupRideId}) -> Route (/routes/{routeId})
* - PRIVACY INVARIANT: ClubMember != GroupRideParticipant.
* Club membership alone never grants access to rider location or ride presence.
*
* ROUTE METRICS CONTRACT:
* - Derived from MotorSignalRoute (/routes/{routeId}).
* - straightLineDistanceMeters: Geodesic Haversine distance between ordered stops (persisted).
* - estimatedDistanceMeters & estimatedDurationSeconds: Optional Valhalla road estimates.
* - When unpopulated or optional, UI displays '--' without fabricating geometry or duration.
*/
export interface ClubUpcomingRide {
id: string;
rideIdentifier: string; // e.g. "MS-ATL-882"
title: string;
scheduledStartAt: string; // formatted or ISO
departureAt?: string;
distanceMiles?: number | null;
durationString?: string | null;
paceClassification: string; // e.g. "Brisk Paceline", "Scenic Pace"
roadCaptainName: string;
roadCaptainMotorcycle?: string | null;
roadCaptainHandle?: string | null;
sweepName?: string | null;
sweepStatus?: 'assigned' | 'vacant' | 'needs_assignment';
confirmedCount: number;
pendingCount: number;
capacity?: number | null;
briefingTime?: string | null;
routeId?: string | null;
}
export interface ClubMetrics {
members: number;
membersActivePercent?: number | null;
membersDeltaMonth?: number | null;
roadMarshalsCount?: number | null;
pendingApplications: number;
upcomingRidesCount: number;
confirmedRidersTotal: number;
nextRideDate?: string | null;
clubMilesThisMonth?: number | null;
completedClubRidesCount: number;
clubMilesAvailable: boolean; // Privacy contract: only eligible completed rides
}
export interface AttentionItem {
id: string;
type: 'application' | 'wall_flag' | 'ride_assignment';
title: string;
subtitle?: string;
severity: 'alert' | 'warning' | 'info';
timestamp?: string;
data?: Record<string, unknown>;
}
export interface ClubActivityItem {
id: string;
type: 'membership' | 'ride' | 'wall' | 'admin';
actorName: string;
actorRole: string;
actionText: string;
targetName?: string;
timestamp: string;
}
export interface UserPortalPermissions {
isAuthenticated: boolean;
uid?: string;
role: ClubRole;
rank?: string;
canManageSettings: boolean;
canInviteMembers: boolean;
canApproveApplications: boolean;
canPlanRides: boolean;
canTransferOwnership: boolean;
}

1143
website/lib/club-data.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,128 @@
import {
Club,
ClubMember,
ClubUpcomingRide,
ClubMetrics,
ClubActivityItem,
} from '../components/founder-portal/types';
/**
* Isolated development & test fixtures based on Stitch Founder Overview reference.
* STRICT ISOLATION: Must NEVER be imported by production runtime loaders.
*/
export const FIXTURE_FOUNDER_CLUB: Club = {
id: 'fixture-ms-atl-01',
name: 'MotorSignal Riders Atlanta',
slug: 'motorsignal-atlanta',
description: 'Premier sport and touring motorcycle syndicate based in northern Georgia. Focused on precision street pacing, group radar safety, and track day telemetry.',
visibility: 'public',
admissionPolicy: 'request',
ownerUid: 'usr-marcus-vance',
memberCount: 126,
createdAt: '2023-03-15T00:00:00.000Z',
updatedAt: '2026-09-08T00:00:00.000Z',
archivedAt: null,
homeRegion: 'Atlanta, Georgia',
motorcycleFocus: 'Sport / Touring',
founderName: 'Marcus Vance',
founderHandle: '@telemetry-marcus',
founderRank: 'President',
foundedYear: '2023',
};
export const FIXTURE_METRICS: ClubMetrics = {
members: 126,
membersActivePercent: null,
membersDeltaMonth: null,
roadMarshalsCount: null,
pendingApplications: 3,
upcomingRidesCount: 2,
confirmedRidersTotal: 38,
nextRideDate: 'Oct 25',
clubMilesThisMonth: null,
completedClubRidesCount: 0,
clubMilesAvailable: false,
};
export const FIXTURE_UPCOMING_RIDES: ClubUpcomingRide[] = [
{
id: 'ride-ms-atl-882',
rideIdentifier: 'MS-ATL-882',
title: 'Blood Mountain & Wolf Pen Gap',
scheduledStartAt: 'Oct 25 · 7:30 AM',
distanceMiles: 142,
durationString: '3h 45m',
paceClassification: 'Brisk Paceline',
roadCaptainName: 'Dave K.',
roadCaptainMotorcycle: 'Yamaha MT-09 SP',
confirmedCount: 24,
pendingCount: 3,
capacity: 28,
briefingTime: '7:15 AM',
routeId: 'rt-blood-mtn-01',
},
{
id: 'ride-ms-atl-883',
rideIdentifier: 'MS-ATL-883',
title: 'Chattahoochee River & Foothills Sweep',
scheduledStartAt: 'Nov 02 · 9:00 AM',
distanceMiles: 88,
durationString: '2h 20m',
paceClassification: 'Scenic Pace',
roadCaptainName: 'Elena Rostova',
roadCaptainMotorcycle: 'Triumph Tiger 900',
sweepStatus: 'needs_assignment',
confirmedCount: 14,
pendingCount: 0,
capacity: 20,
routeId: 'rt-chattahoochee-02',
},
];
export const FIXTURE_PENDING_APPLICATIONS: ClubMember[] = [
{
uid: 'app-david-cho',
clubId: 'fixture-ms-atl-01',
status: 'requested',
role: 'member',
displayName: 'David Cho',
handle: '@davidcho',
motorcycle: 'BMW R1250GS',
location: 'Atlanta, GA',
note: 'Interested in weekend ADV and touring rides.',
updatedAt: '2026-09-08T12:00:00Z',
requestedAt: '2026-09-08T12:00:00Z',
},
{
uid: 'app-elena-rostova',
clubId: 'fixture-ms-atl-01',
status: 'requested',
role: 'member',
displayName: 'Elena Rostova',
handle: '@erostova',
motorcycle: 'Street Triple RS',
location: 'Decatur, GA',
note: 'Looking for spirited mountain group rides.',
updatedAt: '2026-09-08T10:00:00Z',
requestedAt: '2026-09-08T10:00:00Z',
},
{
uid: 'app-marcus-thorne',
clubId: 'fixture-ms-atl-01',
status: 'requested',
role: 'member',
displayName: 'Marcus Thorne',
handle: '@mthorne',
motorcycle: 'Multistrada V4',
location: 'Roswell, GA',
note: 'Sport touring enthusiast, joined via Atlanta riders.',
updatedAt: '2026-09-07T18:00:00Z',
requestedAt: '2026-09-07T18:00:00Z',
},
];
export const CANONICAL_FOUNDER_CLUB = FIXTURE_FOUNDER_CLUB;
export const CANONICAL_METRICS = FIXTURE_METRICS;
export const CANONICAL_UPCOMING_RIDES = FIXTURE_UPCOMING_RIDES;
export const CANONICAL_PENDING_APPLICATIONS = FIXTURE_PENDING_APPLICATIONS;

View File

@ -0,0 +1,840 @@
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(),
runTransaction: async (_db, cb) => cb({ get: async () => ({ exists: () => false }), update: () => {} }),
};
}
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,
updateClubAccessSettings,
fetchEligibleTransferMembers,
transferClubOwnership,
archiveClub,
fetchClubApplications,
approveClubApplication,
declineClubApplication,
} = 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).');
// =========================================================================
// TEST 22: FP-14 Club Settings Implementation Suite (Tests A through X)
// =========================================================================
console.log('Testing: FP-14 Settings Suite (Tests A - X)...');
const settingsPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/settings/page.tsx'), 'utf8');
const settingsEditorSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-settings-editor.tsx'), 'utf8');
// A. Settings auth guard
assert(settingsPageSrc.includes('firebaseAuth.currentUser'), 'Settings page must check firebaseAuth.currentUser');
assert(settingsPageSrc.includes('setUnauthenticated(true)'), 'Settings page must set unauthenticated on missing user');
assert(settingsPageSrc.includes('/login'), 'Settings page must direct unauthenticated users to /login');
// B. Owner/Admin page authorization
assert(settingsPageSrc.includes('ctx.permissions.canManageSettings'), 'Settings page must check canManageSettings');
const ownerPerms = getPortalPermissions('owner');
const adminPerms = getPortalPermissions('admin');
assert.equal(ownerPerms.canManageSettings, true, 'Owner must be authorized to manage settings');
assert.equal(adminPerms.canManageSettings, true, 'Admin must be authorized to access settings page');
// C. Member/non-member denial
const memberPerms = getPortalPermissions('member');
const unauthPerms = getPortalPermissions('member', false);
assert.equal(memberPerms.canManageSettings, false, 'Member must be denied settings management access');
assert.equal(unauthPerms.canManageSettings, false, 'Unauthenticated caller must be denied settings access');
assert(settingsPageSrc.includes('Management Access Restricted'), 'Settings page must show restricted state for members');
// D. visibility enum exactly public/unlisted/private
const updateAccessBody = clubDataSrc.substring(clubDataSrc.indexOf('function updateClubAccessSettings'), clubDataSrc.indexOf('function fetchEligibleTransferMembers'));
assert(updateAccessBody.includes("'public', 'unlisted', 'private'"), 'updateClubAccessSettings must accept only public, unlisted, private');
// E. admission enum exactly open/request/inviteOnly
assert(updateAccessBody.includes("'open', 'request', 'inviteOnly'"), 'updateClubAccessSettings must accept only open, request, inviteOnly');
// F. access mutation whitelist
assert(updateAccessBody.includes('updateDoc(clubRef, {'), 'updateClubAccessSettings must call updateDoc');
assert(updateAccessBody.includes('visibility: input.visibility,'), 'updateClubAccessSettings must write visibility');
assert(updateAccessBody.includes('admissionPolicy: input.admissionPolicy,'), 'updateClubAccessSettings must write admissionPolicy');
assert(updateAccessBody.includes('updatedAt: serverTimestamp(),'), 'updateClubAccessSettings must write updatedAt');
assert(updateAccessBody.includes("m.role === 'owner' || m.role === 'admin'"), 'updateClubAccessSettings must authorize active owner and active admin');
assert(updateAccessBody.includes("m.status === 'active'"), 'updateClubAccessSettings must verify active membership status');
assert(!updateAccessBody.includes('slug:'), 'updateClubAccessSettings MUST NOT write slug');
assert(!updateAccessBody.includes('ownerUid:'), 'updateClubAccessSettings MUST NOT write ownerUid');
assert(!updateAccessBody.includes('memberCount:'), 'updateClubAccessSettings MUST NOT write memberCount');
assert(!updateAccessBody.includes('archivedAt:'), 'updateClubAccessSettings MUST NOT write archivedAt');
assert(!updateAccessBody.includes('name:'), 'updateClubAccessSettings MUST NOT write name');
assert(!updateAccessBody.includes('description:'), 'updateClubAccessSettings MUST NOT write description');
// G. slug immutable
assert(settingsEditorSrc.includes('IMMUTABLE RESERVATION'), 'Settings editor must show immutable reservation badge for slug');
assert(settingsEditorSrc.includes('Club handles are permanently reserved and cannot be renamed or reassigned.'), 'Settings editor must contain canonical immutable handle copy');
assert(!settingsEditorSrc.includes('name="slug"'), 'Settings editor must not have slug input');
// H. rank does not escalate permissions
assert.equal(memberPerms.canTransferOwnership, false, 'Rank does not grant transfer ownership');
assert.equal(memberPerms.canManageSettings, false, 'Rank does not grant manage settings');
assert(settingsEditorSrc.includes('canManageAccess'), 'Settings editor must allow access settings management for owner and admin');
assert(settingsEditorSrc.includes('!canManageAccess'), 'Settings editor must disable access settings for non-leadership');
// I. owner-only ownership transfer
assert.equal(ownerPerms.canTransferOwnership, true, 'Owner can transfer ownership');
assert.equal(adminPerms.canTransferOwnership, false, 'Admin cannot transfer ownership');
const transferBody = clubDataSrc.substring(clubDataSrc.indexOf('function transferClubOwnership'), clubDataSrc.indexOf('function archiveClub'));
assert(transferBody.includes('cd.ownerUid !== currentOwnerUid'), 'transferClubOwnership must check caller is owner');
// J. transfer target must be active member
assert(transferBody.includes("md.status !== 'active'"), 'transferClubOwnership must verify target is active member');
// K. ownership transfer role transition semantics
assert(transferBody.includes("ownerUid: newOwnerUid"), 'transferClubOwnership must update club root ownerUid to newOwnerUid');
assert(transferBody.includes("role: 'admin'"), 'transferClubOwnership must demote current owner to admin');
assert(transferBody.includes("role: 'owner'"), 'transferClubOwnership must promote new owner to owner');
// L. admin cannot transfer ownership
assert(settingsEditorSrc.includes('Transfer Ownership...'), 'Settings editor must include Transfer Ownership trigger');
assert(settingsEditorSrc.includes('!isOwner'), 'Transfer trigger must be disabled for non-owners');
// M. owner-only archive
assert.equal(ownerPerms.role, 'owner', 'Owner role is owner');
assert.equal(adminPerms.role, 'admin', 'Admin role is admin');
assert.equal(ownerPerms.canTransferOwnership, true, 'Owner has exclusive owner transfer authority');
assert.equal(adminPerms.canTransferOwnership, false, 'Admin lacks owner authority');
const archiveBody = clubDataSrc.substring(clubDataSrc.indexOf('function archiveClub'));
assert(archiveBody.includes('cd.ownerUid !== callerUid'), 'archiveClub must verify caller is owner');
// N. admin cannot archive
assert(archiveBody.includes('Permission denied. Only the club owner can archive the club.'), 'archiveClub must throw on non-owner');
// O. archive is not delete
assert(archiveBody.includes('archivedAt: now,'), 'archiveClub must set archivedAt');
assert(!archiveBody.includes('deleteDoc('), 'archiveClub MUST NOT delete document');
// P. no unarchive implementation
assert(!clubDataSrc.includes('unarchiveClub'), 'Must NOT implement unarchiveClub');
assert(!settingsEditorSrc.includes('Unarchive'), 'Settings editor MUST NOT have Unarchive control');
// Q. no personal telemetry/account/billing data
assert(!settingsEditorSrc.includes('leanAngle'), 'Settings editor must not reference leanAngle');
assert(!settingsEditorSrc.includes('speedMph'), 'Settings editor must not reference speedMph');
assert(!settingsEditorSrc.includes('gForce'), 'Settings editor must not reference gForce');
assert(!settingsEditorSrc.includes('stripe'), 'Settings editor must not reference billing');
// R. production fixture isolation
let transferProdThrew = false;
try {
process.env.NODE_ENV = 'production';
await fetchEligibleTransferMembers('test-club', '');
} catch {
transferProdThrew = true;
} finally {
process.env.NODE_ENV = 'test';
}
assert.equal(transferProdThrew, true, 'fetchEligibleTransferMembers must not return mock in production without auth');
// S. sanitized errors
assert(settingsEditorSrc.includes('sanitizePortalError'), 'Settings editor must use sanitizePortalError');
// T. Settings route/sub-navigation state
assert(sidebarSrc.includes("if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;"), 'FounderSidebar must route to settings');
assert(settingsPageSrc.includes('activeNav="settings"'), 'Settings page must pass activeNav="settings"');
assert(settingsPageSrc.includes('<FounderPortalShell'), 'Settings page must reuse FounderPortalShell');
// U. Profile fields are not editable from Settings
assert(!settingsEditorSrc.includes('name="name"'), 'Settings editor must not have name field input');
assert(!settingsEditorSrc.includes('name="description"'), 'Settings editor must not have description input');
assert(!settingsEditorSrc.includes('name="homeRegion"'), 'Settings editor must not have homeRegion input');
assert(!settingsEditorSrc.includes('name="motorcycleFocus"'), 'Settings editor must not have motorcycleFocus input');
// V. no hard delete
assert(!settingsEditorSrc.includes('deleteDoc'), 'Settings editor must NOT call deleteDoc');
assert(!clubDataSrc.includes('deleteDoc('), 'club-data.ts must NOT call deleteDoc');
// W. bounded ownership-target query
const eligibleBody = clubDataSrc.substring(clubDataSrc.indexOf('function fetchEligibleTransferMembers'), clubDataSrc.indexOf('function transferClubOwnership'));
assert(eligibleBody.includes('limit(25)'), 'fetchEligibleTransferMembers must bound query with limit(25)');
assert(eligibleBody.includes("where('status', '==', 'active')"), 'fetchEligibleTransferMembers must query only active members');
// X. no N+1 profile query architecture
assert(!eligibleBody.includes('public_profiles'), 'fetchEligibleTransferMembers must NOT query public_profiles');
assert(!eligibleBody.includes('getDoc('), 'fetchEligibleTransferMembers must NOT call getDoc in loop');
console.log(' PASS: FP-14 Tests A through X verified (Settings auth guard, Owner/Admin authorization, Member denial, visibility enum, admission enum, access mutation whitelist, immutable slug, rank limits, owner-only transfer, active target, atomic role transition, admin transfer restriction, owner-only archive, admin archive restriction, archive is not delete, no unarchive, no telemetry/billing, fixture safety, sanitized errors, sub-routing, profile separation, no hard delete, bounded query, zero N+1 reads).');
// =========================================================================
// TEST 23: FP-15 Applications Suite (Tests A - V)
// =========================================================================
console.log('Testing: FP-15 Applications Suite (Tests A - V)...');
const applicationsPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/applications/page.tsx'), 'utf8');
const applicationsCompSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-applications.tsx'), 'utf8');
// A. Route exists and reuses FounderPortalShell with activeNav="applications"
assert(fs.existsSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/applications/page.tsx')), 'Applications page route must exist');
assert(applicationsPageSrc.includes('<FounderPortalShell'), 'Applications page must reuse FounderPortalShell');
assert(applicationsPageSrc.includes('activeNav="applications"'), 'Applications page must pass activeNav="applications"');
// B. Owner can access
assert.equal(ownerPerms.canApproveApplications, true, 'Owner has canApproveApplications authority');
assert.equal(ownerPerms.canManageSettings, true, 'Owner has canManageSettings authority');
// C. Admin can access
assert.equal(adminPerms.canApproveApplications, true, 'Admin has canApproveApplications authority');
assert.equal(adminPerms.canManageSettings, true, 'Admin has canManageSettings authority');
// D. Member denied
assert.equal(memberPerms.canApproveApplications, false, 'Member must not have canApproveApplications authority');
assert.equal(memberPerms.canManageSettings, false, 'Member must not have canManageSettings authority');
assert(applicationsPageSrc.includes('Management Access Restricted'), 'Applications page must guard against unauthorized members');
// E. Unauthenticated denied
assert.equal(unauthPerms.isAuthenticated, false, 'Unauthenticated user has isAuthenticated = false');
assert.equal(unauthPerms.canApproveApplications, false, 'Unauthenticated user has canApproveApplications = false');
assert(applicationsPageSrc.includes('Authentication Required'), 'Applications page must guard against unauthenticated users');
// F. Pending query bounded to 25
const fetchAppsBody = clubDataSrc.substring(clubDataSrc.indexOf('function fetchClubApplications'), clubDataSrc.indexOf('function approveClubApplication'));
assert(fetchAppsBody.includes('limit(25)'), 'fetchClubApplications must bound query with limit(25)');
// G. Only requested statuses queried
assert(fetchAppsBody.includes("where('status', '==', 'requested')"), 'fetchClubApplications must query status == requested');
// H. Approve semantics: requested -> active
const approveBody = clubDataSrc.substring(clubDataSrc.indexOf('function approveClubApplication'), clubDataSrc.indexOf('function declineClubApplication'));
assert(approveBody.includes("status: 'active'"), 'approveClubApplication must set status: active');
// I. Decline semantics: requested -> declined
const declineBody = clubDataSrc.substring(clubDataSrc.indexOf('function declineClubApplication'));
assert(declineBody.includes("status: 'declined'"), 'declineClubApplication must set status: declined');
// J. Default role: member
assert(approveBody.includes("role: 'member'"), 'approveClubApplication must set role: member');
// K. Default display rank: Member
assert(approveBody.includes("displayRank: 'Member'"), 'approveClubApplication must set displayRank: Member');
// L. memberCount +1 atomic approval behavior
assert(approveBody.includes("memberCount: (cd.memberCount || 0) + 1"), 'approveClubApplication must increment memberCount by 1');
assert(approveBody.includes("lastMembershipChangeUid: targetUid"), 'approveClubApplication must set lastMembershipChangeUid to targetUid');
// M. Decline memberCount delta 0 (no memberCount mutation)
assert(!declineBody.includes('memberCount:'), 'declineClubApplication must not mutate memberCount');
// N. Banned application cannot approve
assert(approveBody.includes("bans', targetUid)"), 'approveClubApplication must check bans subcollection');
assert(approveBody.includes('This application can no longer be approved.'), 'approveClubApplication must reject banned applicant');
// O. Stale request fails safely
assert(approveBody.includes("targetData.status !== 'requested'"), 'approveClubApplication must verify status is requested');
assert(declineBody.includes("targetData.status !== 'requested'"), 'declineClubApplication must verify status is requested');
// P. Duplicate mutation protection
assert(applicationsCompSrc.includes('disabled={Boolean(mutatingUid)}'), 'Buttons must be disabled while mutating');
assert(applicationsCompSrc.includes('if (mutatingUid) return;'), 'Mutation handlers must prevent concurrent double-clicks');
// Q. displayRank does not grant authority
assert.equal(getPortalPermissions('member').canApproveApplications, false, 'Member role cannot approve regardless of display rank');
// R. No private identity fields exposed
assert(!applicationsCompSrc.includes('app.telemetry'), 'Applications component must not expose telemetry data');
assert(!applicationsCompSrc.includes('speedMph'), 'Applications component must not display speedMph');
assert(!applicationsCompSrc.includes('leanAngle'), 'Applications component must not display leanAngle');
assert(!applicationsCompSrc.includes('gForce'), 'Applications component must not display gForce');
assert(!applicationsCompSrc.includes('stripe'), 'Applications component must not display billing info');
assert(!applicationsCompSrc.includes('app.email'), 'Applications component must not display email');
assert(!applicationsCompSrc.includes('app.phone'), 'Applications component must not display phone');
// S. Production failure does not use fixtures
let appProdThrew = false;
try {
process.env.NODE_ENV = 'production';
await fetchClubApplications('test-club', '');
} catch {
appProdThrew = true;
} finally {
process.env.NODE_ENV = 'test';
}
assert.equal(appProdThrew, true, 'fetchClubApplications must reject unauthenticated call in production without fallback');
// T. Applications sidebar active state
assert(sidebarSrc.includes("if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;"), 'FounderSidebar must route to applications');
assert(sidebarSrc.includes("isApplications = item.id === 'applications';"), 'FounderSidebar must treat applications as subroute');
// U. Top bar breadcrumb
assert(topBarSrc.includes("activeNav === 'applications'"), 'FounderTopBar must render applications breadcrumb');
assert(topBarSrc.includes('APPLICATIONS'), 'FounderTopBar must include APPLICATIONS title');
// V. Non-destructive decline (no deletion, history preserved)
assert(!declineBody.includes('deleteDoc('), 'declineClubApplication must NOT delete document');
assert(!applicationsCompSrc.includes('deleteDoc('), 'FounderApplications must NOT delete document');
console.log(' PASS: FP-15 Tests A through V verified (Route exists, Owner/Admin access, Member denial, Unauth denial, Bounded query limit 25, Status requested only, Approve requested->active, Decline requested->declined, Default role member, Default rank Member, memberCount +1 atomic, memberCount delta 0 decline, Ban rejection, Stale request protection, Duplicate click guard, Zero rank privilege, Zero private telemetry/contact leak, Production fixture isolation, Sidebar routing & active state, TopBar breadcrumb, Non-destructive history preservation).');
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (23/23) ---');
}
runAllTests().catch((err) => {
console.error('Test Suite Failure:', err);
process.exit(1);
});