feat(clubs): add public syndicates directory, club discovery, and founder management portal
This commit is contained in:
parent
fa2fccd137
commit
9418aa2ac8
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 & 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"><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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "{searchQuery}".
|
||||
</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>'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>'s request to join this Club? This removes the pending request. The rider'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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 & 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 & 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">
|
||||
“Public Club Profile is organizational identity only. Member telemetry, GPS tracks, personal ride logs, and private account data are not included.”
|
||||
</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
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue