fix: isolate marketing layout checkpoint
This commit is contained in:
parent
bbe6d612a7
commit
cd5201c13a
|
|
@ -1,139 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,675 +0,0 @@
|
|||
'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 */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-[#262D3D]/60">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h1 className="font-display font-bold text-2xl text-white tracking-wide">APPLICATIONS</h1>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-mono font-medium bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||
aria-label={`${applications.length} pending applications`}
|
||||
>
|
||||
{applications.length} PENDING
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Review riders requesting to join this Club.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4 relative overflow-hidden">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
||||
<span>Pending</span>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400 shadow-[0_0_6px_#f59e0b]" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline space-x-2">
|
||||
<span className="font-display font-bold text-2xl text-white">{applications.length}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">Awaiting review</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Admission Policy */}
|
||||
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
||||
<span>Admission Policy</span>
|
||||
<span className="w-2 h-2 rounded-full bg-[#0088FF]" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline space-x-2">
|
||||
<span className="font-display font-bold text-xl text-white uppercase font-mono">
|
||||
{club.admissionPolicy}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">Club join governance rule</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Active Members */}
|
||||
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
||||
<span>Active Members</span>
|
||||
<span className="w-2 h-2 rounded-full bg-[#10B981]" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline space-x-2">
|
||||
<span className="font-display font-bold text-2xl text-[#10B981]">{localMemberCount}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">Current club roster</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Reviewer Authority */}
|
||||
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-mono uppercase tracking-wider">
|
||||
<span>Review Authority</span>
|
||||
<span className="w-2 h-2 rounded-full bg-slate-500" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline space-x-2">
|
||||
<span className="font-display font-bold text-xl text-slate-300 uppercase font-mono">
|
||||
{permissions.role === 'owner' ? 'OWNER ACCESS' : 'ADMIN ACCESS'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">Software administrative rights</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Error Alert */}
|
||||
{actionError && (
|
||||
<div className="p-3 rounded-md 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"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. FILTER & SEARCH TOOLBAR */}
|
||||
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-3 flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Filter Tab */}
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 rounded-md bg-[#0088FF] text-white text-xs font-semibold shadow-sm transition-colors flex items-center space-x-1.5"
|
||||
>
|
||||
<span>All Pending</span>
|
||||
<span className="px-1.5 py-0.2 rounded text-[10px] font-mono bg-black/30 text-white">
|
||||
{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-8 pl-8 pr-8 rounded-md bg-[#141923] border border-[#262D3D] text-xs text-white placeholder-slate-500 focus:outline-none focus:border-[#0088FF]"
|
||||
aria-label="Search applicants"
|
||||
/>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-slate-500 absolute left-2.5 top-2.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 text-slate-500 hover:text-white text-xs"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="flex items-center space-x-1 bg-[#141923] border border-[#262D3D] rounded-md px-2.5 h-8 text-xs text-slate-300">
|
||||
<span className="text-[10px] font-mono text-slate-500 uppercase">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-[#141923] text-white">Newest First</option>
|
||||
<option value="oldest" className="bg-[#141923] text-white">Oldest First</option>
|
||||
<option value="name" className="bg-[#141923] text-white">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. MAIN CONTENT AREA */}
|
||||
{loading ? (
|
||||
<div className="bg-[#0F131B] border border-[#262D3D] rounded-lg p-12 text-center text-slate-400 text-xs">
|
||||
<span className="material-symbols-outlined animate-spin text-2xl text-[#0088FF] mb-2 block">
|
||||
progress_activity
|
||||
</span>
|
||||
Loading pending applications...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
|
||||
<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-3 py-1.5 rounded bg-[#141923] hover:bg-[#181C23] border border-[#262D3D] 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-8 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-[#141923] border border-[#262D3D] 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 uppercase 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-8 rounded-lg bg-[#0F131B] border border-[#262D3D] text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-[#141923] border border-[#262D3D] 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 uppercase 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-[#0088FF] 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 bg-[#0F131B] border border-[#262D3D] rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[#262D3D] bg-[#141923]/70 text-[11px] font-mono text-slate-400 uppercase tracking-wider">
|
||||
<th className="py-3 px-4 font-medium">Rider</th>
|
||||
<th className="py-3 px-4 font-medium">Requested</th>
|
||||
<th className="py-3 px-4 font-medium">Status</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#262D3D]/60 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-[#141923] border-l-2 border-[#0088FF]'
|
||||
: 'hover:bg-[#141923]/50'
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-md bg-[#0088FF]/20 border border-[#0088FF]/60 flex items-center justify-center font-display font-bold text-xs text-[#00F2FE] 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-1.5 py-0.2 rounded text-[9px] font-mono bg-[#0088FF]/20 text-[#00F2FE] border border-[#0088FF]/40 uppercase 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 px-4 font-mono text-[11px] text-slate-300 whitespace-nowrap">
|
||||
{formatDate(app.requestedAt)}
|
||||
</td>
|
||||
<td className="py-3 px-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono font-medium bg-amber-500/10 border border-amber-500/40 text-amber-400">
|
||||
PENDING
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 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-2.5 py-1 rounded bg-[#141923] hover:bg-[#181C23] border border-[#262D3D] 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-2.5 py-1 rounded bg-[#0088FF] text-white text-[11px] font-semibold hover:bg-[#0077e6] transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isMutating ? '...' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(mutatingUid)}
|
||||
onClick={() => setDeclineModalApplicant(app)}
|
||||
className="px-2.5 py-1 rounded bg-transparent hover:bg-rose-500/10 border border-[#262D3D] 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-[#262D3D] bg-[#0F131B] 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="bg-[#141923] border border-[#262D3D] rounded-lg overflow-hidden shadow-lg relative">
|
||||
{/* Top Cyan Accent Line */}
|
||||
<div className="h-0.5 w-full bg-gradient-to-r from-[#0088FF] via-[#00F2FE] to-transparent" />
|
||||
|
||||
<div className="p-5 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-lg bg-[#0088FF]/20 border border-[#0088FF]/60 flex items-center justify-center font-display font-bold text-base text-[#00F2FE]">
|
||||
{getMonogram(selectedApplicant.displayName)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display 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 py-0.5 rounded text-[10px] font-mono font-medium bg-amber-500/15 border border-amber-500/40 text-amber-400">
|
||||
PENDING
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono text-slate-400 bg-[#0A0E15] border border-[#262D3D]">
|
||||
JOIN REQUEST
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#262D3D]" />
|
||||
|
||||
{/* Rider Spec Grid */}
|
||||
<div className="space-y-2.5 text-xs">
|
||||
<div className="flex justify-between items-center py-1 border-b border-[#262D3D]/50">
|
||||
<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-[#262D3D]/50">
|
||||
<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-[#262D3D]/50">
|
||||
<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 rounded-lg bg-[#0A0E15] border border-[#0088FF]/30 space-y-1.5">
|
||||
<div className="flex items-center space-x-1.5 text-[11px] font-mono font-semibold text-[#00F2FE]">
|
||||
<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-2.5 rounded bg-[#0A0E15] border border-[#262D3D] 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-9 rounded-md bg-[#0088FF] hover:bg-[#0077e6] text-white font-semibold text-xs shadow transition-colors flex items-center justify-center space-x-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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-9 rounded-md bg-transparent hover:bg-rose-500/10 border border-[#262D3D] 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"
|
||||
>
|
||||
<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="bg-[#0F131B] border border-[#0088FF]/50 rounded-lg p-5 max-w-md w-full shadow-2xl relative">
|
||||
<div className="flex items-center justify-between pb-2 mb-3 border-b border-[#262D3D]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 rounded-full bg-[#0088FF]" />
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-[#00F2FE]">
|
||||
APPROVAL CONFIRMATION
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-display 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 p-3 rounded bg-[#141923] border border-[#262D3D] space-y-1.5 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-2.5 p-2 rounded bg-[#0A0E15] border border-[#262D3D] 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-4 flex items-center justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(mutatingUid)}
|
||||
onClick={() => setApproveModalApplicant(null)}
|
||||
className="px-3 py-1.5 rounded text-xs text-slate-400 hover:text-white border border-[#262D3D] hover:bg-[#141923] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(mutatingUid)}
|
||||
onClick={() => void handleApprove(approveModalApplicant)}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold text-white bg-[#0088FF] hover:bg-[#0077e6] transition-colors shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{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="bg-[#0F131B] border border-[#262D3D] rounded-lg p-5 max-w-md w-full shadow-2xl relative">
|
||||
<div className="flex items-center justify-between pb-2 mb-3 border-b border-[#262D3D]">
|
||||
<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 uppercase tracking-wider text-slate-300">
|
||||
DECLINE APPLICATION
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-display 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-4 flex items-center justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(mutatingUid)}
|
||||
onClick={() => setDeclineModalApplicant(null)}
|
||||
className="px-3 py-1.5 rounded text-xs text-slate-400 hover:text-white border border-[#262D3D] hover:bg-[#141923] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(mutatingUid)}
|
||||
onClick={() => void handleDecline(declineModalApplicant)}
|
||||
className="px-3 py-1.5 rounded text-xs font-medium text-slate-300 hover:text-rose-400 border border-[#262D3D] hover:border-rose-500/40 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{mutatingUid === declineModalApplicant.uid ? 'Declining...' : 'Decline Application'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Club, UserPortalPermissions, ClubRole } from './types';
|
||||
import { FounderSidebar } from './founder-sidebar';
|
||||
import { FounderTopBar } from './founder-top-bar';
|
||||
|
||||
interface FounderPortalShellProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
activeNav?: string;
|
||||
onNavSelect?: (nav: string) => void;
|
||||
onPlanRide?: () => void;
|
||||
onPostAnnouncement?: () => void;
|
||||
onSwitchRole?: (role: ClubRole) => void;
|
||||
onSwitchClub?: () => void;
|
||||
pendingApplicationsCount?: number;
|
||||
upcomingRidesCount?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FounderPortalShell({
|
||||
club,
|
||||
permissions,
|
||||
activeNav = 'overview',
|
||||
onNavSelect,
|
||||
onPlanRide,
|
||||
onPostAnnouncement,
|
||||
onSwitchRole,
|
||||
onSwitchClub,
|
||||
pendingApplicationsCount = 4,
|
||||
upcomingRidesCount = 2,
|
||||
children,
|
||||
}: FounderPortalShellProps) {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface font-body text-sm text-on-surface antialiased selection:bg-primary selection:text-white">
|
||||
{/* Sidebar (Desktop persistent + Mobile off-canvas drawer) */}
|
||||
<FounderSidebar
|
||||
club={club}
|
||||
permissions={permissions}
|
||||
activeNav={activeNav}
|
||||
onNavSelect={onNavSelect}
|
||||
pendingApplicationsCount={pendingApplicationsCount}
|
||||
upcomingRidesCount={upcomingRidesCount}
|
||||
isOpenOnMobile={mobileSidebarOpen}
|
||||
onCloseMobile={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Main Layout Area */}
|
||||
<div className="lg:pl-72 flex flex-col min-h-screen">
|
||||
{/* Top App Bar */}
|
||||
<FounderTopBar
|
||||
club={club}
|
||||
permissions={permissions}
|
||||
activeNav={activeNav}
|
||||
onToggleMobileSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)}
|
||||
onPlanRide={onPlanRide}
|
||||
onPostAnnouncement={onPostAnnouncement}
|
||||
onSwitchRole={onSwitchRole}
|
||||
onSwitchClub={onSwitchClub}
|
||||
/>
|
||||
|
||||
{/* Content Body Container */}
|
||||
<main className="relative pt-20 px-4 sm:px-6 py-6 w-full max-w-[1440px] mx-auto flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,552 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Club, UserPortalPermissions } from './types';
|
||||
import { updateClubProfile, sanitizePortalError } from '../../lib/club-data';
|
||||
|
||||
interface FounderProfileEditorProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
callerUid?: string;
|
||||
isDemoFixture?: boolean;
|
||||
onProfileUpdated?: (updated: Partial<Club>) => void;
|
||||
}
|
||||
|
||||
export function FounderProfileEditor({
|
||||
club,
|
||||
permissions,
|
||||
callerUid,
|
||||
isDemoFixture = false,
|
||||
onProfileUpdated,
|
||||
}: FounderProfileEditorProps) {
|
||||
// Form fields matching Section 1 & 2
|
||||
const [name, setName] = useState(club.name || '');
|
||||
const [description, setDescription] = useState(club.description || '');
|
||||
const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
|
||||
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
|
||||
|
||||
// Save states
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Validation
|
||||
const trimmedName = name.trim();
|
||||
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
|
||||
const isDescValid = description.trim().length <= 500;
|
||||
const isRegionValid = homeRegion.trim().length <= 100;
|
||||
const isFocusValid = motorcycleFocus.trim().length <= 100;
|
||||
const canSave = isNameValid && isDescValid && isRegionValid && isFocusValid && !isSaving && permissions.canManageSettings;
|
||||
|
||||
const isDirty =
|
||||
trimmedName !== (club.name || '').trim() ||
|
||||
description.trim() !== (club.description || '').trim() ||
|
||||
homeRegion.trim() !== (club.homeRegion || '').trim() ||
|
||||
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim();
|
||||
|
||||
// Short initials for preview emblem
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0].toUpperCase())
|
||||
.slice(0, 3)
|
||||
.join('') || 'MSR';
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSave) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setSaveStatus('idle');
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
if (isDemoFixture) {
|
||||
// Simulated latency in development demo preview mode
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
setSaveStatus('success');
|
||||
onProfileUpdated?.({
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
});
|
||||
} else {
|
||||
if (!callerUid) {
|
||||
throw new Error('Authentication required to save profile.');
|
||||
}
|
||||
await updateClubProfile(club.id, callerUid, {
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
});
|
||||
setSaveStatus('success');
|
||||
onProfileUpdated?.({
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveStatus('error');
|
||||
setErrorMessage(sanitizePortalError(err, 'Failed to update club profile. Please try again.'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
{/* 3. PAGE HEADER */}
|
||||
<section className="border-b border-outline-variant pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-secondary tracking-widest uppercase bg-surface-container px-2 py-0.5 rounded border border-secondary/30">
|
||||
PRECISION, NOT AGGRESSION
|
||||
</span>
|
||||
<span className="font-mono text-xs text-outline tracking-wider uppercase">
|
||||
// {homeRegion ? `${homeRegion.toUpperCase()} ZONE` : 'REGIONAL SQUADRON'}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-on-surface mt-2 uppercase tracking-tight">
|
||||
CLUB PROFILE
|
||||
</h1>
|
||||
<p className="font-body text-sm text-on-surface-variant mt-1">
|
||||
Manage public-facing club identity, branding presentation, and discovery presence.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 4. TOP 4 METRIC CARDS */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 1: Club Name */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">CLUB NAME</span>
|
||||
<span className="w-1.5 h-1.5 bg-secondary rounded-full" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="font-headline text-sm font-semibold text-on-surface truncate">{name || club.name}</div>
|
||||
<div className="font-mono text-[11px] text-secondary mt-1">3-50 chars validated</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Home Region */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">HOME REGION</span>
|
||||
<span className="material-symbols-outlined text-outline text-sm">location_on</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="font-headline text-sm font-semibold text-on-surface truncate">
|
||||
{homeRegion || 'Not specified'}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-outline mt-1">Home Region</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Motorcycle Focus */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">MOTORCYCLE FOCUS</span>
|
||||
<span className="material-symbols-outlined text-outline text-sm">sports_motorsports</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="font-headline text-sm font-semibold text-on-surface truncate">
|
||||
{motorcycleFocus || 'General'}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-outline mt-1">Motorcycle Focus</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Discovery Presence */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">DISCOVERY PRESENCE</span>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
club.visibility === 'public' ? 'bg-emerald-400' : club.visibility === 'unlisted' ? 'bg-amber-400' : 'bg-outline'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div
|
||||
className={`font-headline text-sm font-semibold uppercase ${
|
||||
club.visibility === 'public' ? 'text-emerald-400' : club.visibility === 'unlisted' ? 'text-amber-400' : 'text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{club.visibility}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-outline mt-1">
|
||||
{club.visibility === 'public'
|
||||
? 'Listed in public club index & directory'
|
||||
: club.visibility === 'unlisted'
|
||||
? 'Accessible via direct handle only'
|
||||
: 'Restricted private club'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 5. MAIN WORKSPACE (12-Column Grid) */}
|
||||
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT MAIN (8 cols) */}
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
{/* Status Feedback Banner */}
|
||||
{saveStatus === 'success' && (
|
||||
<div className="p-4 rounded bg-secondary/10 border border-secondary/30 flex items-center gap-3 text-secondary">
|
||||
<span className="material-symbols-outlined text-xl">check_circle</span>
|
||||
<div className="text-xs font-body font-medium">
|
||||
Club profile changes have been saved successfully.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveStatus === 'error' && errorMessage && (
|
||||
<div className="p-4 rounded bg-error-container border border-error/30 flex items-center gap-3 text-error">
|
||||
<span className="material-symbols-outlined text-xl">error</span>
|
||||
<div className="text-xs font-body font-medium">{errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION 1: PUBLIC CLUB IDENTITY */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-secondary text-lg">badge</span>
|
||||
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
|
||||
SECTION 1: PUBLIC CLUB IDENTITY
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Club Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="club-name-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
|
||||
Club Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="club-name-input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!permissions.canManageSettings || isSaving}
|
||||
placeholder="e.g. MotorSignal Riders Atlanta"
|
||||
className="w-full bg-surface-dim border border-outline-variant rounded px-3 py-2 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors"
|
||||
/>
|
||||
<span
|
||||
className={`absolute right-3 top-2.5 font-mono text-[10px] uppercase font-semibold ${
|
||||
isNameValid ? 'text-secondary' : 'text-error'
|
||||
}`}
|
||||
>
|
||||
{isNameValid ? 'VALID' : `${trimmedName.length}/50`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-body text-xs text-outline">
|
||||
3-50 characters. Displayed on public discovery, ride manifests, and club roster.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Club Handle / Identifier (Locked) */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-mono text-[11px] text-outline uppercase font-medium">
|
||||
Club Handle / Identifier
|
||||
</label>
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 bg-surface-container-highest text-outline border border-outline-variant rounded">
|
||||
IMMUTABLE
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<span className="material-symbols-outlined absolute left-3 text-outline text-base">lock</span>
|
||||
<input
|
||||
type="text"
|
||||
value={`@${club.slug}`}
|
||||
readOnly
|
||||
disabled
|
||||
className="w-full bg-surface-container-lowest border border-outline-variant rounded pl-9 pr-3 py-2 text-xs font-mono text-on-surface-variant cursor-not-allowed opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-body text-xs text-outline">
|
||||
Club handles are permanently reserved and cannot be renamed or reassigned.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Club Description */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="club-desc-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
|
||||
Club Description
|
||||
</label>
|
||||
<span className="font-mono text-[10px] text-outline">
|
||||
{description.length}/500
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="club-desc-input"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={!permissions.canManageSettings || isSaving}
|
||||
placeholder="Describe your club's ethos, riding discipline, and mission..."
|
||||
className="w-full bg-surface-dim border border-outline-variant rounded p-3 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors leading-relaxed"
|
||||
/>
|
||||
<p className="font-body text-xs text-outline">
|
||||
Displayed on your public Club profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-secondary text-lg">explore</span>
|
||||
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
|
||||
SECTION 2: REGIONAL & MOTORCYCLE FOCUS
|
||||
</h2>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] text-outline">GEO / DISCIPLINE</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Home Region */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="club-region-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
|
||||
Home Region
|
||||
</label>
|
||||
<input
|
||||
id="club-region-input"
|
||||
type="text"
|
||||
value={homeRegion}
|
||||
onChange={(e) => setHomeRegion(e.target.value)}
|
||||
disabled={!permissions.canManageSettings || isSaving}
|
||||
placeholder="e.g. Atlanta, Georgia"
|
||||
className="w-full bg-surface-dim border border-outline-variant rounded px-3 py-2 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors"
|
||||
/>
|
||||
<p className="font-body text-xs text-outline">Primary area where your Club is based.</p>
|
||||
</div>
|
||||
|
||||
{/* Motorcycle Focus */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="club-focus-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
|
||||
Motorcycle Focus
|
||||
</label>
|
||||
<input
|
||||
id="club-focus-input"
|
||||
type="text"
|
||||
value={motorcycleFocus}
|
||||
onChange={(e) => setMotorcycleFocus(e.target.value)}
|
||||
disabled={!permissions.canManageSettings || isSaving}
|
||||
placeholder="e.g. Sport / Touring"
|
||||
className="w-full bg-surface-dim border border-outline-variant rounded px-3 py-2 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors"
|
||||
/>
|
||||
<p className="font-body text-xs text-outline">
|
||||
Main riding style or motorcycle focus for your Club.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: LOGO & BANNER (Presentation Only) */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-secondary text-lg">palette</span>
|
||||
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
|
||||
SECTION 3: LOGO & BANNER
|
||||
</h2>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] text-outline">VISUAL ASSETS</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Logo Presentation Card */}
|
||||
<div className="bg-surface-dim border border-outline-variant rounded p-4 flex flex-col justify-between space-y-3">
|
||||
<div className="font-mono text-[11px] text-outline uppercase">
|
||||
<span>CLUB EMBLEM</span>
|
||||
</div>
|
||||
<div className="h-36 bg-surface-container-lowest border border-dashed border-outline-variant rounded flex flex-col items-center justify-center relative overflow-hidden">
|
||||
{club.logoUrl ? (
|
||||
<img src={club.logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain" />
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-lg border-2 border-secondary bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative">
|
||||
<span className="material-symbols-outlined text-secondary text-2xl">two_wheeler</span>
|
||||
<span className="font-headline text-secondary font-bold text-xs tracking-widest mt-1">
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-outline">
|
||||
<span className="material-symbols-outlined text-xs">info</span>
|
||||
<span>Logo and banner editing is not available yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner Presentation Card */}
|
||||
<div className="bg-surface-dim border border-outline-variant rounded p-4 flex flex-col justify-between space-y-3">
|
||||
<div className="font-mono text-[11px] text-outline uppercase">
|
||||
<span>BANNER PRESENTATION</span>
|
||||
</div>
|
||||
<div className="h-36 bg-surface-container-lowest border border-dashed border-outline-variant rounded relative overflow-hidden flex items-center justify-center">
|
||||
{club.bannerUrl ? (
|
||||
<img
|
||||
src={club.bannerUrl}
|
||||
alt="Club banner presentation"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface-container-low flex items-center justify-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(#3491ff_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
|
||||
<div className="flex items-center gap-2 font-mono text-xs text-outline z-10">
|
||||
<span className="material-symbols-outlined text-base text-secondary">image</span>
|
||||
<span className="uppercase tracking-wider font-semibold">{club.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-outline">
|
||||
<span className="material-symbols-outlined text-xs">info</span>
|
||||
<span>Logo and banner editing is not available yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACTION DOCKED FOOTER */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 font-body text-xs text-outline">
|
||||
<span className="material-symbols-outlined text-secondary text-base">check_circle</span>
|
||||
<span>Saves public club profile information.</span>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave || (!isDirty && saveStatus !== 'error')}
|
||||
className={`w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-2.5 rounded font-headline text-xs font-semibold uppercase tracking-wider transition-all duration-150 ${
|
||||
canSave && (isDirty || saveStatus === 'error')
|
||||
? 'bg-primary-container hover:bg-primary text-on-primary active:scale-[0.99] shadow-[0_0_15px_rgba(52,145,255,0.3)] cursor-pointer'
|
||||
: 'bg-surface-container-high text-outline cursor-not-allowed opacity-60'
|
||||
}`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
|
||||
<span>Saving Changes...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-base">check</span>
|
||||
<span>Save Profile Changes</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT RAIL (4 cols) */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
{/* CARD A: PUBLIC DIRECTORY PREVIEW (Live Simulation) */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-4 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-outline-variant pb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-secondary text-base">visibility</span>
|
||||
<span className="font-headline text-xs text-on-surface font-semibold uppercase">
|
||||
DIRECTORY PREVIEW
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] bg-surface-container-high text-secondary px-2 py-0.5 rounded border border-secondary/30">
|
||||
LIVE SIMULATION
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Embedded Public Card Mockup (IDENTITY ONLY, ZERO METRICS) */}
|
||||
<div className="bg-surface-container border border-outline-variant rounded overflow-hidden shadow-lg">
|
||||
{/* Banner Mockup */}
|
||||
<div className="h-24 bg-surface-container-high relative overflow-hidden">
|
||||
{club.bannerUrl ? (
|
||||
<img
|
||||
src={club.bannerUrl}
|
||||
alt="Directory banner preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface-container-low flex items-center justify-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(#3491ff_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-surface-container" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 pt-0 relative">
|
||||
{/* Emblem overlapping */}
|
||||
<div className="-mt-8 w-14 h-14 rounded bg-surface-dim border-2 border-secondary flex flex-col items-center justify-center text-secondary shadow-md">
|
||||
<span className="material-symbols-outlined text-xl">two_wheeler</span>
|
||||
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center">
|
||||
<h3 className="font-headline text-sm text-on-surface font-bold truncate">
|
||||
{name || club.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-[11px] text-outline mt-0.5">@{club.slug}</div>
|
||||
|
||||
<div className="mt-2 inline-flex items-center gap-1.5 font-mono text-[10px] text-secondary bg-surface-container-highest px-2 py-0.5 rounded">
|
||||
<span className="material-symbols-outlined text-xs">location_on</span>
|
||||
<span className="truncate">
|
||||
{homeRegion || 'Global'} • {motorcycleFocus || 'General'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 font-body text-xs text-on-surface-variant line-clamp-2 leading-relaxed">
|
||||
{description || 'No club description provided yet.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-[10px] text-outline text-center">
|
||||
Rendered in standard public directory feed (1:1 responsive layout)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CARD B: ACCESS & DISCOVERY BOUNDARY */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-on-surface border-b border-outline-variant pb-2.5">
|
||||
<span className="material-symbols-outlined text-secondary text-base">admin_panel_settings</span>
|
||||
<h3 className="font-headline text-xs uppercase font-semibold">ACCESS CONTROLS</h3>
|
||||
</div>
|
||||
<p className="font-body text-xs text-on-surface-variant leading-relaxed">
|
||||
Access settings (Visibility & Admission Policy) are managed separately under Club Settings.
|
||||
</p>
|
||||
<div className="pt-1 flex items-center">
|
||||
<span className="inline-flex items-center gap-1.5 text-secondary font-mono text-xs cursor-default">
|
||||
<span>Manage Access Settings</span>
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CARD C: PRIVACY CONTRACT */}
|
||||
<div className="bg-surface-dim border border-outline-variant rounded p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-secondary">
|
||||
<span className="material-symbols-outlined text-base">lock</span>
|
||||
<span className="font-mono text-xs uppercase font-bold tracking-wider">PRIVACY CONTRACT</span>
|
||||
</div>
|
||||
<blockquote className="font-body text-xs italic text-on-surface border-l-2 border-secondary pl-3 py-1 bg-surface-container-lowest/50 rounded-r leading-relaxed">
|
||||
“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-outline">
|
||||
Telemetry and member activity are strictly isolated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,285 +0,0 @@
|
|||
'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-secondary' : ''
|
||||
}`}
|
||||
style={isActive && isSubRoute ? { fontVariationSettings: "'FILL' 1" } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className={isActive && isSubRoute ? 'text-secondary font-semibold' : ''}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span
|
||||
className={`font-mono text-[10px] px-1.5 py-0.5 rounded ${
|
||||
item.badgeType === 'primary' && isActive
|
||||
? 'bg-white/20 text-white font-medium'
|
||||
: item.badgeType === 'error'
|
||||
? 'bg-error-container text-on-error-container font-semibold border border-error/30'
|
||||
: item.badgeType === 'secondary'
|
||||
? 'bg-secondary/10 text-secondary border border-secondary/30 font-semibold'
|
||||
: 'bg-surface-container-high text-on-surface-variant border border-outline-variant'
|
||||
}`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
{isActive && isSubRoute && !item.badge && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClasses = `w-full flex items-center justify-between px-3 py-2 rounded transition-colors text-xs ${
|
||||
isActive
|
||||
? isSubRoute
|
||||
? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'
|
||||
: 'bg-primary text-white font-medium'
|
||||
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface font-normal'
|
||||
}`;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={href}
|
||||
onClick={() => {
|
||||
onNavSelect?.(item.id);
|
||||
onCloseMobile?.();
|
||||
}}
|
||||
className={baseClasses}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
className={baseClasses}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
{isOpenOnMobile && (
|
||||
<div
|
||||
onClick={onCloseMobile}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40 lg:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-full w-72 bg-surface-container-lowest z-50 flex flex-col justify-between border-r border-outline-variant transition-transform duration-200 ease-in-out ${
|
||||
isOpenOnMobile ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
}`}
|
||||
aria-label="Founder Portal Sidebar"
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Brand Header */}
|
||||
<div className="h-16 px-5 flex items-center justify-between border-b border-outline-variant bg-surface-container-lowest">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className="w-8 h-8 rounded bg-surface-container-high border border-outline-variant flex items-center justify-center text-primary group-hover:border-primary/50 transition-colors">
|
||||
<span className="material-symbols-outlined text-[20px]">two_wheeler</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-headline font-semibold text-[15px] tracking-tight text-on-surface">
|
||||
MOTORSIGNAL
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-outline px-1.5 py-0.5 rounded bg-surface-container-high border border-outline-variant">
|
||||
PORTAL
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-secondary tracking-wider uppercase font-medium">
|
||||
CLUB MANAGEMENT
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
onClick={onCloseMobile}
|
||||
className="lg:hidden text-outline hover:text-on-surface p-1"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current Club Context */}
|
||||
<div className="px-5 py-3 border-b border-outline-variant bg-surface-container-low/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-outline tracking-wider uppercase font-medium">
|
||||
CURRENT CLUB
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 font-mono text-[10px] text-secondary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
||||
ONLINE / SYNCED
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between font-body text-xs text-on-surface">
|
||||
<span className="truncate font-semibold">{club.name}</span>
|
||||
<span className="font-mono text-[11px] text-outline ml-2 shrink-0">{shortCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
|
||||
{navItemsTop.map(renderNavItem)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<div className="h-px bg-outline-variant/60 w-full" />
|
||||
</div>
|
||||
|
||||
{navItemsMiddle.map(renderNavItem)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<div className="h-px bg-outline-variant/60 w-full" />
|
||||
</div>
|
||||
|
||||
{navItemsBottom.map(renderNavItem)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Bottom Founder Profile */}
|
||||
<div className="p-3 border-t border-outline-variant bg-surface-container-low">
|
||||
<div className="flex items-center justify-between p-2 rounded bg-surface-container border border-outline-variant">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded bg-primary flex items-center justify-center shrink-0">
|
||||
<span className="material-symbols-outlined text-white text-[18px]">person</span>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-body text-xs font-semibold text-on-surface truncate">
|
||||
{club.founderName ?? 'Marcus Vance'}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[13px] text-secondary shrink-0">
|
||||
verified
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-outline truncate">
|
||||
{permissions.role === 'owner' ? 'Owner & President' : permissions.role.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/account"
|
||||
className="text-outline hover:text-on-surface transition-colors p-1"
|
||||
title="Account Settings"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Club, UserPortalPermissions, ClubRole } from './types';
|
||||
|
||||
interface FounderTopBarProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
activeNav?: string;
|
||||
onToggleMobileSidebar?: () => void;
|
||||
onPlanRide?: () => void;
|
||||
onPostAnnouncement?: () => void;
|
||||
onSwitchRole?: (role: ClubRole) => void;
|
||||
onSwitchClub?: () => void;
|
||||
}
|
||||
|
||||
export function FounderTopBar({
|
||||
club,
|
||||
permissions,
|
||||
activeNav,
|
||||
onToggleMobileSidebar,
|
||||
onPlanRide,
|
||||
onPostAnnouncement,
|
||||
onSwitchRole,
|
||||
onSwitchClub,
|
||||
}: FounderTopBarProps) {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-outline-variant z-40 px-4 sm:px-6 flex items-center justify-between gap-4">
|
||||
{/* Left: Mobile hamburger + Breadcrumbs + Switch Club */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
|
||||
{/* Mobile Hamburger Toggle */}
|
||||
<button
|
||||
onClick={onToggleMobileSidebar}
|
||||
className="lg:hidden text-outline hover:text-on-surface p-1 rounded hover:bg-surface-container transition-colors"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[22px]">menu</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0">
|
||||
<Link href="/clubs" className="hover:text-on-surface transition-colors">
|
||||
CLUBS
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
href={`/clubs/${club.slug || club.id}/manage`}
|
||||
className="text-on-surface font-semibold truncate max-w-[120px] sm:max-w-[200px] md:max-w-none uppercase hover:text-primary transition-colors"
|
||||
>
|
||||
{club.name}
|
||||
</Link>
|
||||
{activeNav === 'profile' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span className="text-secondary font-semibold uppercase">CLUB PROFILE</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'settings' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span className="text-secondary font-semibold uppercase">SETTINGS</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'applications' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span className="text-secondary font-semibold uppercase">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 bg-surface-container border border-outline-variant text-on-surface font-body text-xs hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px] text-secondary">swap_horiz</span>
|
||||
<span className="hidden sm:inline">Switch Club</span>
|
||||
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: Search Input (desktop/tablet only) */}
|
||||
<div className="flex-1 max-w-md mx-4 hidden md:block">
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
className="w-full h-8 pl-9 pr-3 rounded bg-surface-container-low border border-outline-variant text-on-surface placeholder:text-outline/70 font-body text-xs focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="Search members, rides, routes..."
|
||||
type="text"
|
||||
aria-label="Search members, rides, routes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */}
|
||||
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0">
|
||||
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
|
||||
{process.env.NODE_ENV === 'development' ? (
|
||||
<div
|
||||
className="hidden xl:flex items-center gap-1 px-2 py-0.5 rounded bg-surface-container-high border border-outline-variant text-[11px] font-mono text-outline"
|
||||
title="Interactive role preview for testing software permissions (Development Only)"
|
||||
>
|
||||
<span className="text-outline-variant">DEV PREVIEW:</span>
|
||||
{(['owner', 'admin', 'member'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onSwitchRole?.(r)}
|
||||
className={`px-1.5 py-0.5 rounded uppercase font-semibold transition-colors ${
|
||||
permissions.role === r
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded bg-surface-container border border-outline-variant text-[11px] font-mono text-outline uppercase font-medium"
|
||||
title="Authoritative software role"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
||||
<span className="text-on-surface font-semibold">{permissions.role}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post Announcement (Owner / Admin only) */}
|
||||
{permissions.canManageSettings && (
|
||||
<button
|
||||
onClick={onPostAnnouncement}
|
||||
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded bg-surface-container border border-outline-variant text-on-surface font-body text-xs font-medium hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px] text-secondary">campaign</span>
|
||||
<span>Post Announcement</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Plan Club Ride (Owner / Admin only) */}
|
||||
{permissions.canPlanRides && (
|
||||
<button
|
||||
onClick={onPlanRide}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-primary text-white font-body text-xs font-semibold hover:bg-primary-container transition-colors shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px]">add_road</span>
|
||||
<span className="hidden sm:inline">Plan Club Ride</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative ml-1">
|
||||
<button
|
||||
className="w-8 h-8 rounded bg-surface-container border border-outline-variant flex items-center justify-center text-outline hover:text-on-surface transition-colors"
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">notifications</span>
|
||||
</button>
|
||||
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-error" />
|
||||
</div>
|
||||
|
||||
{/* Account Avatar */}
|
||||
<Link
|
||||
href="/account"
|
||||
className="w-7 h-7 rounded bg-primary flex items-center justify-center ml-0.5 hover:ring-2 hover:ring-primary-container transition-all"
|
||||
title="Account profile"
|
||||
aria-label="Account profile"
|
||||
>
|
||||
<span className="material-symbols-outlined text-white text-[16px]">person</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,840 +0,0 @@
|
|||
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