feat: add founder club profile and sub-routing foundation
This commit is contained in:
parent
912b4d2a45
commit
5eb47e8c09
|
|
@ -0,0 +1,152 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState, use } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
fetchFounderClubContext,
|
||||
FounderClubContext,
|
||||
sanitizePortalError,
|
||||
} from '../../../../../lib/club-data';
|
||||
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
|
||||
import { FounderProfileEditor } from '../../../../../components/founder-portal/founder-profile-editor';
|
||||
import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state';
|
||||
import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state';
|
||||
import { firebaseAuth } from '../../../../../lib/firebase-client';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ clubId: string }>;
|
||||
}
|
||||
|
||||
export default function ClubFounderProfilePage({ params }: PageProps) {
|
||||
const { clubId } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [context, setContext] = useState<FounderClubContext | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unauthenticated, setUnauthenticated] = useState(false);
|
||||
const [unauthorized, setUnauthorized] = useState(false);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setUnauthenticated(false);
|
||||
setUnauthorized(false);
|
||||
|
||||
try {
|
||||
await firebaseAuth.authStateReady();
|
||||
const currentUser = firebaseAuth.currentUser;
|
||||
const isDemoPreview = searchParams?.get('preview') === 'demo';
|
||||
|
||||
// Unauthenticated Guard: Direct access without demo preview requires authentication
|
||||
if (!isDemoPreview && !currentUser) {
|
||||
setUnauthenticated(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, {
|
||||
allowDemoPreview: isDemoPreview,
|
||||
});
|
||||
|
||||
// Authorization Guard: Only Owner and Admin can manage Club Profile
|
||||
if (!isDemoPreview && !ctx.permissions.canManageSettings) {
|
||||
setUnauthorized(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setContext(ctx);
|
||||
} catch (err) {
|
||||
setError(sanitizePortalError(err, "Club profile couldn't be loaded."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [clubId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface p-6">
|
||||
<OverviewSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Unauthenticated safe error state
|
||||
if (unauthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
||||
<SafeErrorState
|
||||
title="Authentication Required"
|
||||
message="Sign in with your rider account to access club profile settings."
|
||||
actionLabel="Sign In"
|
||||
onAction={() => router.push('/login')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Unauthorized (Member or non-admin) safe error state
|
||||
if (unauthorized) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
||||
<SafeErrorState
|
||||
title="Management Access Restricted"
|
||||
message="You must be an Owner or Admin of this Club to access Club Profile settings."
|
||||
actionLabel="Return to Clubs"
|
||||
onAction={() => router.push('/clubs')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error or missing context
|
||||
if (error || !context) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
||||
<SafeErrorState
|
||||
title="Profile Unavailable"
|
||||
message={error ?? "Club profile couldn't be loaded."}
|
||||
actionLabel="Return to Clubs"
|
||||
onAction={() => router.push('/clubs')}
|
||||
onRetry={loadData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FounderPortalShell
|
||||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
activeNav="profile"
|
||||
pendingApplicationsCount={4}
|
||||
upcomingRidesCount={2}
|
||||
>
|
||||
<FounderProfileEditor
|
||||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
callerUid={context.currentUser.uid}
|
||||
isDemoFixture={context.isDemoFixture}
|
||||
onProfileUpdated={(updated) => {
|
||||
setContext((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
club: {
|
||||
...prev.club,
|
||||
...updated,
|
||||
},
|
||||
}
|
||||
: null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FounderPortalShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Club, UserPortalPermissions, ClubRole } from './types';
|
||||
import { FounderSidebar } from './founder-sidebar';
|
||||
import { FounderTopBar } from './founder-top-bar';
|
||||
|
||||
interface FounderPortalShellProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
activeNav?: string;
|
||||
onNavSelect?: (nav: string) => void;
|
||||
onPlanRide?: () => void;
|
||||
onPostAnnouncement?: () => void;
|
||||
onSwitchRole?: (role: ClubRole) => void;
|
||||
onSwitchClub?: () => void;
|
||||
pendingApplicationsCount?: number;
|
||||
upcomingRidesCount?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FounderPortalShell({
|
||||
club,
|
||||
permissions,
|
||||
activeNav = 'overview',
|
||||
onNavSelect,
|
||||
onPlanRide,
|
||||
onPostAnnouncement,
|
||||
onSwitchRole,
|
||||
onSwitchClub,
|
||||
pendingApplicationsCount = 4,
|
||||
upcomingRidesCount = 2,
|
||||
children,
|
||||
}: FounderPortalShellProps) {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface font-body text-sm text-on-surface antialiased selection:bg-primary selection:text-white">
|
||||
{/* Sidebar (Desktop persistent + Mobile off-canvas drawer) */}
|
||||
<FounderSidebar
|
||||
club={club}
|
||||
permissions={permissions}
|
||||
activeNav={activeNav}
|
||||
onNavSelect={onNavSelect}
|
||||
pendingApplicationsCount={pendingApplicationsCount}
|
||||
upcomingRidesCount={upcomingRidesCount}
|
||||
isOpenOnMobile={mobileSidebarOpen}
|
||||
onCloseMobile={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Main Layout Area */}
|
||||
<div className="lg:pl-72 flex flex-col min-h-screen">
|
||||
{/* Top App Bar */}
|
||||
<FounderTopBar
|
||||
club={club}
|
||||
permissions={permissions}
|
||||
activeNav={activeNav}
|
||||
onToggleMobileSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)}
|
||||
onPlanRide={onPlanRide}
|
||||
onPostAnnouncement={onPostAnnouncement}
|
||||
onSwitchRole={onSwitchRole}
|
||||
onSwitchClub={onSwitchClub}
|
||||
/>
|
||||
|
||||
{/* Content Body Container */}
|
||||
<main className="relative pt-20 px-4 sm:px-6 py-6 w-full max-w-[1440px] mx-auto flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,552 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Club, UserPortalPermissions } from './types';
|
||||
import { updateClubProfile, sanitizePortalError } from '../../lib/club-data';
|
||||
|
||||
interface FounderProfileEditorProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
callerUid?: string;
|
||||
isDemoFixture?: boolean;
|
||||
onProfileUpdated?: (updated: Partial<Club>) => void;
|
||||
}
|
||||
|
||||
export function FounderProfileEditor({
|
||||
club,
|
||||
permissions,
|
||||
callerUid,
|
||||
isDemoFixture = false,
|
||||
onProfileUpdated,
|
||||
}: FounderProfileEditorProps) {
|
||||
// Form fields matching Section 1 & 2
|
||||
const [name, setName] = useState(club.name || '');
|
||||
const [description, setDescription] = useState(club.description || '');
|
||||
const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
|
||||
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
|
||||
|
||||
// Save states
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Validation
|
||||
const trimmedName = name.trim();
|
||||
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
|
||||
const isDescValid = description.trim().length <= 500;
|
||||
const isRegionValid = homeRegion.trim().length <= 100;
|
||||
const isFocusValid = motorcycleFocus.trim().length <= 100;
|
||||
const canSave = isNameValid && isDescValid && isRegionValid && isFocusValid && !isSaving && permissions.canManageSettings;
|
||||
|
||||
const isDirty =
|
||||
trimmedName !== (club.name || '').trim() ||
|
||||
description.trim() !== (club.description || '').trim() ||
|
||||
homeRegion.trim() !== (club.homeRegion || '').trim() ||
|
||||
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim();
|
||||
|
||||
// Short initials for preview emblem
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0].toUpperCase())
|
||||
.slice(0, 3)
|
||||
.join('') || 'MSR';
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSave) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setSaveStatus('idle');
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
if (isDemoFixture) {
|
||||
// Simulated latency in development demo preview mode
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
setSaveStatus('success');
|
||||
onProfileUpdated?.({
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
});
|
||||
} else {
|
||||
if (!callerUid) {
|
||||
throw new Error('Authentication required to save profile.');
|
||||
}
|
||||
await updateClubProfile(club.id, callerUid, {
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
});
|
||||
setSaveStatus('success');
|
||||
onProfileUpdated?.({
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveStatus('error');
|
||||
setErrorMessage(sanitizePortalError(err, 'Failed to update club profile. Please try again.'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
{/* 3. PAGE HEADER */}
|
||||
<section className="border-b border-outline-variant pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-secondary tracking-widest uppercase bg-surface-container px-2 py-0.5 rounded border border-secondary/30">
|
||||
PRECISION, NOT AGGRESSION
|
||||
</span>
|
||||
<span className="font-mono text-xs text-outline tracking-wider uppercase">
|
||||
// {homeRegion ? `${homeRegion.toUpperCase()} ZONE` : 'REGIONAL SQUADRON'}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-on-surface mt-2 uppercase tracking-tight">
|
||||
CLUB PROFILE
|
||||
</h1>
|
||||
<p className="font-body text-sm text-on-surface-variant mt-1">
|
||||
Manage public-facing club identity, branding presentation, and discovery presence.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 4. TOP 4 METRIC CARDS */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 1: Club Name */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">CLUB NAME</span>
|
||||
<span className="w-1.5 h-1.5 bg-secondary rounded-full" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="font-headline text-sm font-semibold text-on-surface truncate">{name || club.name}</div>
|
||||
<div className="font-mono text-[11px] text-secondary mt-1">3-50 chars validated</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Home Region */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">HOME REGION</span>
|
||||
<span className="material-symbols-outlined text-outline text-sm">location_on</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="font-headline text-sm font-semibold text-on-surface truncate">
|
||||
{homeRegion || 'Not specified'}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-outline mt-1">Home Region</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Motorcycle Focus */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">MOTORCYCLE FOCUS</span>
|
||||
<span className="material-symbols-outlined text-outline text-sm">sports_motorsports</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="font-headline text-sm font-semibold text-on-surface truncate">
|
||||
{motorcycleFocus || 'General'}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-outline mt-1">Motorcycle Focus</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Discovery Presence */}
|
||||
<div className="bg-surface-container-low border border-outline-variant p-3.5 rounded flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] uppercase text-outline">DISCOVERY PRESENCE</span>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
club.visibility === 'public' ? 'bg-emerald-400' : club.visibility === 'unlisted' ? 'bg-amber-400' : 'bg-outline'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div
|
||||
className={`font-headline text-sm font-semibold uppercase ${
|
||||
club.visibility === 'public' ? 'text-emerald-400' : club.visibility === 'unlisted' ? 'text-amber-400' : 'text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{club.visibility}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-outline mt-1">
|
||||
{club.visibility === 'public'
|
||||
? 'Listed in public club index & directory'
|
||||
: club.visibility === 'unlisted'
|
||||
? 'Accessible via direct handle only'
|
||||
: 'Restricted private club'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 5. MAIN WORKSPACE (12-Column Grid) */}
|
||||
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT MAIN (8 cols) */}
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
{/* Status Feedback Banner */}
|
||||
{saveStatus === 'success' && (
|
||||
<div className="p-4 rounded bg-secondary/10 border border-secondary/30 flex items-center gap-3 text-secondary">
|
||||
<span className="material-symbols-outlined text-xl">check_circle</span>
|
||||
<div className="text-xs font-body font-medium">
|
||||
Club profile changes have been saved successfully.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveStatus === 'error' && errorMessage && (
|
||||
<div className="p-4 rounded bg-error-container border border-error/30 flex items-center gap-3 text-error">
|
||||
<span className="material-symbols-outlined text-xl">error</span>
|
||||
<div className="text-xs font-body font-medium">{errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION 1: PUBLIC CLUB IDENTITY */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-secondary text-lg">badge</span>
|
||||
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
|
||||
SECTION 1: PUBLIC CLUB IDENTITY
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Club Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="club-name-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
|
||||
Club Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="club-name-input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!permissions.canManageSettings || isSaving}
|
||||
placeholder="e.g. MotorSignal Riders Atlanta"
|
||||
className="w-full bg-surface-dim border border-outline-variant rounded px-3 py-2 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors"
|
||||
/>
|
||||
<span
|
||||
className={`absolute right-3 top-2.5 font-mono text-[10px] uppercase font-semibold ${
|
||||
isNameValid ? 'text-secondary' : 'text-error'
|
||||
}`}
|
||||
>
|
||||
{isNameValid ? 'VALID' : `${trimmedName.length}/50`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-body text-xs text-outline">
|
||||
3-50 characters. Displayed on public discovery, ride manifests, and club roster.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Club Handle / Identifier (Locked) */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-mono text-[11px] text-outline uppercase font-medium">
|
||||
Club Handle / Identifier
|
||||
</label>
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 bg-surface-container-highest text-outline border border-outline-variant rounded">
|
||||
IMMUTABLE
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<span className="material-symbols-outlined absolute left-3 text-outline text-base">lock</span>
|
||||
<input
|
||||
type="text"
|
||||
value={`@${club.slug}`}
|
||||
readOnly
|
||||
disabled
|
||||
className="w-full bg-surface-container-lowest border border-outline-variant rounded pl-9 pr-3 py-2 text-xs font-mono text-on-surface-variant cursor-not-allowed opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-body text-xs text-outline">
|
||||
Club handles are permanently reserved and cannot be renamed or reassigned.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Club Description */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="club-desc-input" className="font-mono text-[11px] text-outline uppercase block font-medium">
|
||||
Club Description
|
||||
</label>
|
||||
<span className="font-mono text-[10px] text-outline">
|
||||
{description.length}/500
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="club-desc-input"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={!permissions.canManageSettings || isSaving}
|
||||
placeholder="Describe your club's ethos, riding discipline, and mission..."
|
||||
className="w-full bg-surface-dim border border-outline-variant rounded p-3 text-sm font-body text-on-surface focus:border-primary focus:ring-0 outline-none transition-colors leading-relaxed"
|
||||
/>
|
||||
<p className="font-body text-xs text-outline">
|
||||
Displayed on your public Club profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
|
||||
<div className="bg-surface-container-low border border-outline-variant rounded p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-secondary text-lg">explore</span>
|
||||
<h2 className="font-headline text-sm uppercase font-semibold text-on-surface">
|
||||
SECTION 2: REGIONAL & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Club, UserPortalPermissions } from './types';
|
||||
|
||||
interface FounderSidebarProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
activeNav?: string;
|
||||
onNavSelect?: (nav: string) => void;
|
||||
pendingApplicationsCount?: number;
|
||||
upcomingRidesCount?: number;
|
||||
isOpenOnMobile?: boolean;
|
||||
onCloseMobile?: () => void;
|
||||
}
|
||||
|
||||
export function FounderSidebar({
|
||||
club,
|
||||
permissions,
|
||||
activeNav = 'overview',
|
||||
onNavSelect,
|
||||
pendingApplicationsCount = 4,
|
||||
upcomingRidesCount = 2,
|
||||
isOpenOnMobile = false,
|
||||
onCloseMobile,
|
||||
}: FounderSidebarProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const isDemo = searchParams?.get('preview') === 'demo';
|
||||
const queryParam = isDemo ? '?preview=demo' : '';
|
||||
const clubParam = club.slug || club.id;
|
||||
|
||||
const shortCode = club.name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0].toUpperCase())
|
||||
.slice(0, 3)
|
||||
.join('') || 'ATL';
|
||||
|
||||
const navItemsTop = [
|
||||
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary' },
|
||||
{ id: 'profile', label: 'Club Profile', icon: 'shield' },
|
||||
{ id: 'members', label: 'Members', icon: 'group', badge: club.memberCount.toString(), badgeType: 'neutral' },
|
||||
{
|
||||
id: 'applications',
|
||||
label: 'Applications',
|
||||
icon: 'person_add',
|
||||
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} ALERT` : undefined,
|
||||
badgeType: 'error',
|
||||
},
|
||||
{ id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech' },
|
||||
];
|
||||
|
||||
const navItemsMiddle = [
|
||||
{
|
||||
id: 'rides',
|
||||
label: 'Rides',
|
||||
icon: 'route',
|
||||
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount} ACTIVE` : undefined,
|
||||
badgeType: 'secondary',
|
||||
},
|
||||
{ id: 'routes', label: 'Routes', icon: 'map' },
|
||||
{ id: 'wall', label: 'Wall', icon: 'forum' },
|
||||
{ id: 'chat', label: 'Chat', icon: 'chat' },
|
||||
{ id: 'stats', label: 'Stats', icon: 'query_stats' },
|
||||
];
|
||||
|
||||
const navItemsBottom = [
|
||||
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: '1', badgeType: 'neutral' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
function getRouteHref(id: string) {
|
||||
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
|
||||
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handleItemClick(id: string) {
|
||||
onNavSelect?.(id);
|
||||
onCloseMobile?.();
|
||||
}
|
||||
|
||||
function renderNavItem(item: { id: string; label: string; icon: string; badge?: string; badgeType?: string }) {
|
||||
const isActive = activeNav === item.id;
|
||||
const href = getRouteHref(item.id);
|
||||
const isProfile = item.id === 'profile';
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${
|
||||
isActive && isProfile ? 'text-secondary' : ''
|
||||
}`}
|
||||
style={isActive && isProfile ? { fontVariationSettings: "'FILL' 1" } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className={isActive && isProfile ? 'text-secondary font-semibold' : ''}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span
|
||||
className={`font-mono text-[10px] px-1.5 py-0.5 rounded ${
|
||||
item.badgeType === 'primary' && isActive
|
||||
? 'bg-white/20 text-white font-medium'
|
||||
: item.badgeType === 'error'
|
||||
? 'bg-error-container text-on-error-container font-semibold border border-error/30'
|
||||
: item.badgeType === 'secondary'
|
||||
? 'bg-secondary/10 text-secondary border border-secondary/30 font-semibold'
|
||||
: 'bg-surface-container-high text-on-surface-variant border border-outline-variant'
|
||||
}`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
{isActive && isProfile && !item.badge && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClasses = `w-full flex items-center justify-between px-3 py-2 rounded transition-colors text-xs ${
|
||||
isActive
|
||||
? isProfile
|
||||
? 'bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'
|
||||
: 'bg-primary text-white font-medium'
|
||||
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface font-normal'
|
||||
}`;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={href}
|
||||
onClick={() => {
|
||||
onNavSelect?.(item.id);
|
||||
onCloseMobile?.();
|
||||
}}
|
||||
className={baseClasses}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
className={baseClasses}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
{isOpenOnMobile && (
|
||||
<div
|
||||
onClick={onCloseMobile}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40 lg:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-full w-72 bg-surface-container-lowest z-50 flex flex-col justify-between border-r border-outline-variant transition-transform duration-200 ease-in-out ${
|
||||
isOpenOnMobile ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
}`}
|
||||
aria-label="Founder Portal Sidebar"
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Brand Header */}
|
||||
<div className="h-16 px-5 flex items-center justify-between border-b border-outline-variant bg-surface-container-lowest">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className="w-8 h-8 rounded bg-surface-container-high border border-outline-variant flex items-center justify-center text-primary group-hover:border-primary/50 transition-colors">
|
||||
<span className="material-symbols-outlined text-[20px]">two_wheeler</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-headline font-semibold text-[15px] tracking-tight text-on-surface">
|
||||
MOTORSIGNAL
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-outline px-1.5 py-0.5 rounded bg-surface-container-high border border-outline-variant">
|
||||
PORTAL
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-secondary tracking-wider uppercase font-medium">
|
||||
CLUB MANAGEMENT
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
onClick={onCloseMobile}
|
||||
className="lg:hidden text-outline hover:text-on-surface p-1"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current Club Context */}
|
||||
<div className="px-5 py-3 border-b border-outline-variant bg-surface-container-low/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-outline tracking-wider uppercase font-medium">
|
||||
CURRENT CLUB
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 font-mono text-[10px] text-secondary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
||||
ONLINE / SYNCED
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between font-body text-xs text-on-surface">
|
||||
<span className="truncate font-semibold">{club.name}</span>
|
||||
<span className="font-mono text-[11px] text-outline ml-2 shrink-0">{shortCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
|
||||
{navItemsTop.map(renderNavItem)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<div className="h-px bg-outline-variant/60 w-full" />
|
||||
</div>
|
||||
|
||||
{navItemsMiddle.map(renderNavItem)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<div className="h-px bg-outline-variant/60 w-full" />
|
||||
</div>
|
||||
|
||||
{navItemsBottom.map(renderNavItem)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Bottom Founder Profile */}
|
||||
<div className="p-3 border-t border-outline-variant bg-surface-container-low">
|
||||
<div className="flex items-center justify-between p-2 rounded bg-surface-container border border-outline-variant">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded bg-primary flex items-center justify-center shrink-0">
|
||||
<span className="material-symbols-outlined text-white text-[18px]">person</span>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-body text-xs font-semibold text-on-surface truncate">
|
||||
{club.founderName ?? 'Marcus Vance'}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[13px] text-secondary shrink-0">
|
||||
verified
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-outline truncate">
|
||||
{permissions.role === 'owner' ? 'Owner & President' : permissions.role.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/account"
|
||||
className="text-outline hover:text-on-surface transition-colors p-1"
|
||||
title="Account Settings"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Club, UserPortalPermissions, ClubRole } from './types';
|
||||
|
||||
interface FounderTopBarProps {
|
||||
club: Club;
|
||||
permissions: UserPortalPermissions;
|
||||
activeNav?: string;
|
||||
onToggleMobileSidebar?: () => void;
|
||||
onPlanRide?: () => void;
|
||||
onPostAnnouncement?: () => void;
|
||||
onSwitchRole?: (role: ClubRole) => void;
|
||||
onSwitchClub?: () => void;
|
||||
}
|
||||
|
||||
export function FounderTopBar({
|
||||
club,
|
||||
permissions,
|
||||
activeNav,
|
||||
onToggleMobileSidebar,
|
||||
onPlanRide,
|
||||
onPostAnnouncement,
|
||||
onSwitchRole,
|
||||
onSwitchClub,
|
||||
}: FounderTopBarProps) {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-outline-variant z-40 px-4 sm:px-6 flex items-center justify-between gap-4">
|
||||
{/* Left: Mobile hamburger + Breadcrumbs + Switch Club */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
|
||||
{/* Mobile Hamburger Toggle */}
|
||||
<button
|
||||
onClick={onToggleMobileSidebar}
|
||||
className="lg:hidden text-outline hover:text-on-surface p-1 rounded hover:bg-surface-container transition-colors"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[22px]">menu</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0">
|
||||
<Link href="/clubs" className="hover:text-on-surface transition-colors">
|
||||
CLUBS
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
href={`/clubs/${club.slug || club.id}/manage`}
|
||||
className="text-on-surface font-semibold truncate max-w-[120px] sm:max-w-[200px] md:max-w-none uppercase hover:text-primary transition-colors"
|
||||
>
|
||||
{club.name}
|
||||
</Link>
|
||||
{activeNav === 'profile' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span className="text-secondary font-semibold uppercase">CLUB PROFILE</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center shrink-0">
|
||||
<button
|
||||
onClick={onSwitchClub}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded bg-surface-container border border-outline-variant text-on-surface font-body text-xs hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px] text-secondary">swap_horiz</span>
|
||||
<span className="hidden sm:inline">Switch Club</span>
|
||||
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: Search Input (desktop/tablet only) */}
|
||||
<div className="flex-1 max-w-md mx-4 hidden md:block">
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
className="w-full h-8 pl-9 pr-3 rounded bg-surface-container-low border border-outline-variant text-on-surface placeholder:text-outline/70 font-body text-xs focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="Search members, rides, routes..."
|
||||
type="text"
|
||||
aria-label="Search members, rides, routes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */}
|
||||
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0">
|
||||
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
|
||||
{process.env.NODE_ENV === 'development' ? (
|
||||
<div
|
||||
className="hidden xl:flex items-center gap-1 px-2 py-0.5 rounded bg-surface-container-high border border-outline-variant text-[11px] font-mono text-outline"
|
||||
title="Interactive role preview for testing software permissions (Development Only)"
|
||||
>
|
||||
<span className="text-outline-variant">DEV PREVIEW:</span>
|
||||
{(['owner', 'admin', 'member'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onSwitchRole?.(r)}
|
||||
className={`px-1.5 py-0.5 rounded uppercase font-semibold transition-colors ${
|
||||
permissions.role === r
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded bg-surface-container border border-outline-variant text-[11px] font-mono text-outline uppercase font-medium"
|
||||
title="Authoritative software role"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
|
||||
<span className="text-on-surface font-semibold">{permissions.role}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post Announcement (Owner / Admin only) */}
|
||||
{permissions.canManageSettings && (
|
||||
<button
|
||||
onClick={onPostAnnouncement}
|
||||
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded bg-surface-container border border-outline-variant text-on-surface font-body text-xs font-medium hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px] text-secondary">campaign</span>
|
||||
<span>Post Announcement</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Plan Club Ride (Owner / Admin only) */}
|
||||
{permissions.canPlanRides && (
|
||||
<button
|
||||
onClick={onPlanRide}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-primary text-white font-body text-xs font-semibold hover:bg-primary-container transition-colors shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px]">add_road</span>
|
||||
<span className="hidden sm:inline">Plan Club Ride</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative ml-1">
|
||||
<button
|
||||
className="w-8 h-8 rounded bg-surface-container border border-outline-variant flex items-center justify-center text-outline hover:text-on-surface transition-colors"
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">notifications</span>
|
||||
</button>
|
||||
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-error" />
|
||||
</div>
|
||||
|
||||
{/* Account Avatar */}
|
||||
<Link
|
||||
href="/account"
|
||||
className="w-7 h-7 rounded bg-primary flex items-center justify-center ml-0.5 hover:ring-2 hover:ring-primary-container transition-all"
|
||||
title="Account profile"
|
||||
aria-label="Account profile"
|
||||
>
|
||||
<span className="material-symbols-outlined text-white text-[16px]">person</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,667 @@
|
|||
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp } from 'firebase/firestore';
|
||||
import { firestore } from './firebase-client';
|
||||
import {
|
||||
Club,
|
||||
ClubMember,
|
||||
ClubUpcomingRide,
|
||||
ClubMetrics,
|
||||
UserPortalPermissions,
|
||||
ClubActivityItem,
|
||||
ClubRole,
|
||||
} from '../components/founder-portal/types';
|
||||
import {
|
||||
FIXTURE_FOUNDER_CLUB,
|
||||
FIXTURE_METRICS,
|
||||
FIXTURE_UPCOMING_RIDES,
|
||||
FIXTURE_PENDING_APPLICATIONS,
|
||||
} from './club-fixtures';
|
||||
|
||||
export interface ManageableClubSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
role: ClubRole;
|
||||
displayRank?: string;
|
||||
memberCount: number;
|
||||
homeRegion?: string;
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates software permissions derived solely from Administrative Role.
|
||||
* Club Display Rank does NOT grant software administrative access.
|
||||
*/
|
||||
export function getPortalPermissions(role: ClubRole, isAuthenticated = true): UserPortalPermissions {
|
||||
if (!isAuthenticated) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
role: 'member',
|
||||
canManageSettings: false,
|
||||
canInviteMembers: false,
|
||||
canApproveApplications: false,
|
||||
canPlanRides: false,
|
||||
canTransferOwnership: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isOwner = role === 'owner';
|
||||
const isAdmin = role === 'admin' || isOwner;
|
||||
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
role,
|
||||
canManageSettings: isAdmin,
|
||||
canInviteMembers: isAdmin,
|
||||
canApproveApplications: isAdmin,
|
||||
canPlanRides: isAdmin,
|
||||
canTransferOwnership: isOwner,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes backend errors into closed, user-safe messages.
|
||||
* Prevents raw exception leakage, Firestore paths, internal codes, or stack traces.
|
||||
*/
|
||||
export function sanitizePortalError(error: unknown, fallback = 'Something went wrong. Please try again.'): string {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('[FounderPortal Internal Debug]:', error);
|
||||
}
|
||||
|
||||
if (!error) return fallback;
|
||||
|
||||
const errString = String(error);
|
||||
if (
|
||||
errString.includes('permission-denied') ||
|
||||
errString.includes('unauthenticated') ||
|
||||
errString.includes('PERMISSION_DENIED') ||
|
||||
errString.includes('unauthorized')
|
||||
) {
|
||||
return 'Club information is restricted or requires authentication.';
|
||||
}
|
||||
if (errString.includes('not-found') || errString.includes('NOT_FOUND')) {
|
||||
return 'Club information could not be found.';
|
||||
}
|
||||
if (
|
||||
errString.includes('unavailable') ||
|
||||
errString.includes('network') ||
|
||||
errString.includes('DEADLINE_EXCEEDED') ||
|
||||
errString.includes('UNAVAILABLE')
|
||||
) {
|
||||
return "Club information couldn't be loaded. Check your network connection.";
|
||||
}
|
||||
|
||||
// Any internal precondition or unhandled error falls back to generic safe text
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely fetches club details from Firestore.
|
||||
* PRODUCTION RUNTIME NEVER SILENTLY FALLS BACK TO DEMO DATA.
|
||||
* Dev preview is only returned if explicitly opted into in development mode.
|
||||
*/
|
||||
export async function fetchClubData(
|
||||
clubIdOrSlug: string,
|
||||
callerUid?: string,
|
||||
options?: { allowDemoPreview?: boolean }
|
||||
): Promise<{
|
||||
club: Club;
|
||||
metrics: ClubMetrics;
|
||||
upcomingRides: ClubUpcomingRide[];
|
||||
pendingApplications: ClubMember[];
|
||||
activities: ClubActivityItem[];
|
||||
userRole: ClubRole;
|
||||
userRank: string;
|
||||
}> {
|
||||
const normalized = (clubIdOrSlug || '').trim().toLowerCase();
|
||||
|
||||
// Explicit opt-in development preview fixture ONLY
|
||||
if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') {
|
||||
return {
|
||||
club: FIXTURE_FOUNDER_CLUB,
|
||||
metrics: FIXTURE_METRICS,
|
||||
upcomingRides: FIXTURE_UPCOMING_RIDES,
|
||||
pendingApplications: FIXTURE_PENDING_APPLICATIONS,
|
||||
activities: [],
|
||||
userRole: 'owner',
|
||||
userRank: 'President',
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
try {
|
||||
let resolvedClubId = clubIdOrSlug;
|
||||
|
||||
// Try finding slug in club_handles first
|
||||
try {
|
||||
const handleRef = doc(firestore, 'club_handles', normalized);
|
||||
const handleSnap = await getDoc(handleRef);
|
||||
if (handleSnap.exists()) {
|
||||
resolvedClubId = handleSnap.data().clubId || clubIdOrSlug;
|
||||
}
|
||||
} catch {
|
||||
// Slug lookup failed; proceed to query direct club document
|
||||
}
|
||||
|
||||
const clubRef = doc(firestore, 'clubs', resolvedClubId);
|
||||
const clubSnap = await getDoc(clubRef);
|
||||
|
||||
if (!clubSnap.exists()) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
const d = clubSnap.data();
|
||||
if (d.archivedAt) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
// Authoritative Founder Identity Resolution:
|
||||
// Club.ownerUid establishes ownership identity only.
|
||||
// Canonical rider profile lookup: /public_profiles/{ownerUid} -> displayName, username.
|
||||
// Conservative fallback when unauthenticated, restricted, or private.
|
||||
// Never persist or expect a redundant 'founderName' field in /clubs/{clubId}.
|
||||
let founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder';
|
||||
let founderHandle: string | undefined = undefined;
|
||||
|
||||
if (d.ownerUid) {
|
||||
try {
|
||||
const ownerProfileSnap = await getDoc(doc(firestore, 'public_profiles', d.ownerUid));
|
||||
if (ownerProfileSnap.exists()) {
|
||||
const profileData = ownerProfileSnap.data();
|
||||
if (callerUid !== d.ownerUid && profileData.displayName) {
|
||||
founderName = profileData.displayName;
|
||||
}
|
||||
if (profileData.username) {
|
||||
founderHandle = `@${profileData.username}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Point read restricted, blocked, unauthenticated, or unavailable: maintain conservative fallback
|
||||
}
|
||||
}
|
||||
|
||||
const club: Club = {
|
||||
id: clubSnap.id,
|
||||
name: d.name || 'Unnamed Club',
|
||||
slug: d.slug || normalized,
|
||||
description: d.description || '',
|
||||
visibility: d.visibility || 'public',
|
||||
admissionPolicy: d.admissionPolicy || 'request',
|
||||
ownerUid: d.ownerUid || '',
|
||||
memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1,
|
||||
createdAt: d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : (d.createdAt || new Date().toISOString()),
|
||||
updatedAt: d.updatedAt?.toDate?.() ? d.updatedAt.toDate().toISOString() : (d.updatedAt || new Date().toISOString()),
|
||||
archivedAt: d.archivedAt?.toDate?.() ? d.archivedAt.toDate().toISOString() : (d.archivedAt || null),
|
||||
logoUrl: d.logoUrl || null,
|
||||
bannerUrl: d.bannerUrl || null,
|
||||
homeRegion: d.homeRegion || 'Global',
|
||||
motorcycleFocus: d.motorcycleFocus || 'General',
|
||||
founderName,
|
||||
founderHandle,
|
||||
founderRank: 'Founder',
|
||||
foundedYear: d.createdAt?.toDate?.() ? String(d.createdAt.toDate().getFullYear()) : '2024',
|
||||
};
|
||||
|
||||
// Determine current user's membership role
|
||||
let userRole: ClubRole = 'member';
|
||||
let userRank = 'Member';
|
||||
|
||||
if (callerUid) {
|
||||
if (callerUid === club.ownerUid) {
|
||||
userRole = 'owner';
|
||||
userRank = 'President';
|
||||
} else {
|
||||
try {
|
||||
const myMemberSnap = await getDoc(doc(firestore, 'clubs', resolvedClubId, 'members', callerUid));
|
||||
if (myMemberSnap.exists()) {
|
||||
const mData = myMemberSnap.data();
|
||||
if (mData.status === 'active') {
|
||||
userRole = (mData.role as ClubRole) || 'member';
|
||||
userRank = mData.displayRank || (userRole === 'admin' ? 'Officer' : 'Member');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Point read restricted: default to standard member
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pending applications ONLY if caller is verified owner or admin
|
||||
let pendingApps: ClubMember[] = [];
|
||||
if (userRole === 'owner' || userRole === 'admin') {
|
||||
try {
|
||||
const appsQuery = query(
|
||||
collection(firestore, 'clubs', resolvedClubId, 'members'),
|
||||
where('status', '==', 'requested'),
|
||||
limit(10)
|
||||
);
|
||||
const appsSnap = await getDocs(appsQuery);
|
||||
pendingApps = appsSnap.docs.map((s) => {
|
||||
const ad = s.data();
|
||||
return {
|
||||
uid: s.id,
|
||||
clubId: resolvedClubId,
|
||||
status: 'requested',
|
||||
role: 'member',
|
||||
displayName: ad.displayName || ad.handle || 'Applicant',
|
||||
handle: ad.handle ? `@${ad.handle}` : `@rider_${s.id.slice(0, 6)}`,
|
||||
motorcycle: ad.motorcycle || undefined,
|
||||
location: ad.location || undefined,
|
||||
note: ad.note || undefined,
|
||||
updatedAt: ad.updatedAt?.toDate?.() ? ad.updatedAt.toDate().toISOString() : new Date().toISOString(),
|
||||
requestedAt: ad.requestedAt?.toDate?.() ? ad.requestedAt.toDate().toISOString() : new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Query not permitted or rules constrained: safely empty
|
||||
}
|
||||
}
|
||||
|
||||
// Authoritative Upcoming Club Rides Query:
|
||||
// CANONICAL LINK: GroupRide.clubId == club.id
|
||||
// Strictly unaffiliated from organizer membership, participant lists, accessUids, or live presence.
|
||||
// PRIVACY INVARIANT: ClubMember != GroupRideParticipant (location sharing requires accepted ride participation).
|
||||
// ROUTE METRICS: Derived from MotorSignalRoute. Optional road duration/distance fallback to '--'.
|
||||
let upcomingRides: ClubUpcomingRide[] = [];
|
||||
try {
|
||||
const ridesQuery = query(
|
||||
collection(firestore, 'group_rides'),
|
||||
where('clubId', '==', resolvedClubId),
|
||||
limit(10)
|
||||
);
|
||||
const ridesSnap = await getDocs(ridesQuery);
|
||||
upcomingRides = ridesSnap.docs.map((s) => {
|
||||
const rd = s.data();
|
||||
return {
|
||||
id: s.id,
|
||||
rideIdentifier: rd.rideIdentifier || `RIDE-${s.id.slice(0, 6).toUpperCase()}`,
|
||||
title: rd.title || 'Untitled Club Ride',
|
||||
scheduledStartAt: rd.scheduledStartAt?.toDate?.()
|
||||
? rd.scheduledStartAt.toDate().toLocaleDateString()
|
||||
: (rd.scheduledStartAt || 'TBD'),
|
||||
departureAt: rd.departureAt?.toDate?.()
|
||||
? rd.departureAt.toDate().toLocaleDateString()
|
||||
: rd.departureAt,
|
||||
distanceMiles: typeof rd.routeDistanceMeters === 'number'
|
||||
? Math.round(rd.routeDistanceMeters * 0.000621371)
|
||||
: null,
|
||||
durationString: null, // Road duration is optional/transient; '--' if unpopulated
|
||||
paceClassification: rd.paceClassification || 'Club Paceline',
|
||||
roadCaptainName: rd.organizerDisplayName || 'Ride Leader',
|
||||
roadCaptainHandle: rd.organizerHandle ? `@${rd.organizerHandle}` : undefined,
|
||||
roadCaptainMotorcycle: rd.organizerMotorcycle || null,
|
||||
sweepName: rd.sweepName || null,
|
||||
sweepStatus: rd.sweepStatus || 'vacant',
|
||||
confirmedCount: typeof rd.participantCount === 'number' ? rd.participantCount : 1,
|
||||
pendingCount: typeof rd.pendingCount === 'number' ? rd.pendingCount : 0,
|
||||
capacity: rd.participantLimit || null,
|
||||
briefingTime: rd.briefingTime || null,
|
||||
routeId: rd.routeId || null,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Group rides query restricted or index unready: safely empty
|
||||
}
|
||||
|
||||
// Truthful metrics derived strictly from schema:
|
||||
// No fabricated deltas (+6), active percent (98%), or road marshal counts
|
||||
const metrics: ClubMetrics = {
|
||||
members: club.memberCount,
|
||||
membersActivePercent: null,
|
||||
membersDeltaMonth: null,
|
||||
roadMarshalsCount: null,
|
||||
pendingApplications: pendingApps.length,
|
||||
upcomingRidesCount: upcomingRides.length,
|
||||
confirmedRidersTotal: upcomingRides.reduce((sum, r) => sum + r.confirmedCount, 0),
|
||||
nextRideDate: upcomingRides[0]?.scheduledStartAt || null,
|
||||
clubMilesThisMonth: null,
|
||||
completedClubRidesCount: 0,
|
||||
clubMilesAvailable: false,
|
||||
};
|
||||
|
||||
return {
|
||||
club,
|
||||
metrics,
|
||||
upcomingRides,
|
||||
pendingApplications: pendingApps,
|
||||
activities: [],
|
||||
userRole,
|
||||
userRank,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(sanitizePortalError(error, "Club information couldn't be loaded."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated Club Discovery:
|
||||
* Returns only clubs where caller is club owner (ownerUid == callerUid)
|
||||
* or active club admin (role == 'admin' in /clubs/{clubId}/members/{callerUid}).
|
||||
* Ordinary members NEVER see their clubs in the management list.
|
||||
*/
|
||||
export async function fetchManageableClubs(callerUid: string): Promise<ManageableClubSummary[]> {
|
||||
if (!callerUid) return [];
|
||||
|
||||
const clubMap = new Map<string, ManageableClubSummary>();
|
||||
|
||||
try {
|
||||
// 1. Query clubs owned by user
|
||||
const ownedQuery = query(
|
||||
collection(firestore, 'clubs'),
|
||||
where('ownerUid', '==', callerUid)
|
||||
);
|
||||
const ownedSnap = await getDocs(ownedQuery);
|
||||
|
||||
for (const docSnap of ownedSnap.docs) {
|
||||
const d = docSnap.data();
|
||||
if (!d.archivedAt) {
|
||||
clubMap.set(docSnap.id, {
|
||||
id: docSnap.id,
|
||||
name: d.name || 'Unnamed Club',
|
||||
slug: d.slug,
|
||||
role: 'owner',
|
||||
displayRank: 'President',
|
||||
memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1,
|
||||
homeRegion: d.homeRegion || 'Global',
|
||||
logoUrl: d.logoUrl || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Query memberships where user is memberUid and role is admin
|
||||
const membersQuery = query(
|
||||
collectionGroup(firestore, 'members'),
|
||||
where('uid', '==', callerUid)
|
||||
);
|
||||
const memberSnap = await getDocs(membersQuery);
|
||||
|
||||
for (const mDoc of memberSnap.docs) {
|
||||
const mData = mDoc.data();
|
||||
if (mData.status === 'active' && mData.role === 'admin') {
|
||||
const clubId = mData.clubId || mDoc.ref.parent.parent?.id;
|
||||
if (clubId && !clubMap.has(clubId)) {
|
||||
try {
|
||||
const clubSnap = await getDoc(doc(firestore, 'clubs', clubId));
|
||||
if (clubSnap.exists()) {
|
||||
const cd = clubSnap.data();
|
||||
if (!cd.archivedAt) {
|
||||
clubMap.set(clubId, {
|
||||
id: clubSnap.id,
|
||||
name: cd.name || 'Unnamed Club',
|
||||
slug: cd.slug,
|
||||
role: 'admin',
|
||||
displayRank: mData.displayRank || 'Officer',
|
||||
memberCount: typeof cd.memberCount === 'number' ? cd.memberCount : 1,
|
||||
homeRegion: cd.homeRegion || 'Global',
|
||||
logoUrl: cd.logoUrl || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Point read failure for club
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('[fetchManageableClubs Debug]:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(clubMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolated Context for Founder Portal Sub-Routes.
|
||||
* Enforces O(1) point-read query gate (zero collection queries).
|
||||
*/
|
||||
export interface FounderClubContext {
|
||||
club: Club;
|
||||
currentUser: {
|
||||
uid: string;
|
||||
role: ClubRole;
|
||||
displayRank?: string;
|
||||
};
|
||||
ownerProfile: {
|
||||
displayName: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
permissions: UserPortalPermissions;
|
||||
isDemoFixture: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes strictly O(1) point-reads to resolve club context for Founder sub-routes.
|
||||
* Does NOT query group_rides, club_applications, or member rosters.
|
||||
*/
|
||||
export async function fetchFounderClubContext(
|
||||
clubIdOrSlug: string,
|
||||
callerUid?: string,
|
||||
options?: { allowDemoPreview?: boolean }
|
||||
): Promise<FounderClubContext> {
|
||||
const normalized = (clubIdOrSlug || '').trim().toLowerCase();
|
||||
|
||||
// Explicit opt-in development preview fixture ONLY
|
||||
if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') {
|
||||
return {
|
||||
club: FIXTURE_FOUNDER_CLUB,
|
||||
currentUser: {
|
||||
uid: callerUid || FIXTURE_FOUNDER_CLUB.ownerUid,
|
||||
role: 'owner',
|
||||
displayRank: 'President',
|
||||
},
|
||||
ownerProfile: {
|
||||
displayName: FIXTURE_FOUNDER_CLUB.founderName || 'Marcus Vance',
|
||||
username: 'marcus_vance',
|
||||
},
|
||||
permissions: getPortalPermissions('owner', true),
|
||||
isDemoFixture: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
let resolvedClubId = clubIdOrSlug;
|
||||
|
||||
// 1. Point read: resolve slug via club_handles
|
||||
try {
|
||||
const handleRef = doc(firestore, 'club_handles', normalized);
|
||||
const handleSnap = await getDoc(handleRef);
|
||||
if (handleSnap.exists()) {
|
||||
resolvedClubId = handleSnap.data().clubId || clubIdOrSlug;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to direct ID
|
||||
}
|
||||
|
||||
// 2. Point read: clubs/{clubId}
|
||||
const clubRef = doc(firestore, 'clubs', resolvedClubId);
|
||||
const clubSnap = await getDoc(clubRef);
|
||||
|
||||
if (!clubSnap.exists()) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
const d = clubSnap.data();
|
||||
if (d.archivedAt) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
// 3. Point read: public_profiles/{ownerUid}
|
||||
let ownerProfile: { displayName: string; username?: string } | null = null;
|
||||
let founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder';
|
||||
let founderHandle: string | undefined = undefined;
|
||||
|
||||
if (d.ownerUid) {
|
||||
try {
|
||||
const ownerSnap = await getDoc(doc(firestore, 'public_profiles', d.ownerUid));
|
||||
if (ownerSnap.exists()) {
|
||||
const pd = ownerSnap.data();
|
||||
if (pd.displayName) {
|
||||
founderName = callerUid === d.ownerUid ? 'You' : pd.displayName;
|
||||
ownerProfile = {
|
||||
displayName: pd.displayName,
|
||||
username: pd.username,
|
||||
};
|
||||
}
|
||||
if (pd.username) {
|
||||
founderHandle = `@${pd.username}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Conservative fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve caller role & permissions
|
||||
let callerRole: ClubRole = 'member';
|
||||
let callerRank = 'Member';
|
||||
let isCallerAuth = Boolean(callerUid);
|
||||
|
||||
if (callerUid) {
|
||||
if (callerUid === d.ownerUid) {
|
||||
callerRole = 'owner';
|
||||
callerRank = 'President';
|
||||
} else {
|
||||
try {
|
||||
const myMemberSnap = await getDoc(doc(firestore, 'clubs', resolvedClubId, 'members', callerUid));
|
||||
if (myMemberSnap.exists()) {
|
||||
const mData = myMemberSnap.data();
|
||||
if (mData.status === 'active') {
|
||||
callerRole = (mData.role === 'admin' || mData.role === 'owner') ? mData.role : 'member';
|
||||
callerRank = mData.displayRank || (callerRole === 'admin' ? 'Officer' : 'Member');
|
||||
} else {
|
||||
isCallerAuth = false;
|
||||
}
|
||||
} else {
|
||||
isCallerAuth = false;
|
||||
}
|
||||
} catch {
|
||||
isCallerAuth = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const club: Club = {
|
||||
id: clubSnap.id,
|
||||
name: d.name || 'Unnamed Club',
|
||||
slug: d.slug || normalized,
|
||||
description: d.description || '',
|
||||
visibility: d.visibility || 'public',
|
||||
admissionPolicy: d.admissionPolicy || 'request',
|
||||
ownerUid: d.ownerUid || '',
|
||||
memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1,
|
||||
createdAt: d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : (d.createdAt || new Date().toISOString()),
|
||||
updatedAt: d.updatedAt?.toDate?.() ? d.updatedAt.toDate().toISOString() : (d.updatedAt || new Date().toISOString()),
|
||||
archivedAt: d.archivedAt?.toDate?.() ? d.archivedAt.toDate().toISOString() : (d.archivedAt || null),
|
||||
logoUrl: d.logoUrl || null,
|
||||
bannerUrl: d.bannerUrl || null,
|
||||
homeRegion: d.homeRegion || 'Global',
|
||||
motorcycleFocus: d.motorcycleFocus || 'General',
|
||||
founderName,
|
||||
founderHandle,
|
||||
founderRank: 'Founder',
|
||||
foundedYear: d.createdAt?.toDate?.() ? String(d.createdAt.toDate().getFullYear()) : '2024',
|
||||
};
|
||||
|
||||
const permissions = getPortalPermissions(callerRole, isCallerAuth);
|
||||
|
||||
return {
|
||||
club,
|
||||
currentUser: {
|
||||
uid: callerUid || '',
|
||||
role: callerRole,
|
||||
displayRank: callerRank,
|
||||
},
|
||||
ownerProfile,
|
||||
permissions,
|
||||
isDemoFixture: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateClubProfileInput {
|
||||
name: string;
|
||||
description: string;
|
||||
homeRegion: string;
|
||||
motorcycleFocus: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist update of club public profile metadata.
|
||||
* Strictly verifies caller is owner or admin.
|
||||
* Forbids mutating slug, visibility, admissionPolicy, ownerUid, memberCount, archivedAt.
|
||||
*/
|
||||
export async function updateClubProfile(
|
||||
clubId: string,
|
||||
callerUid: string,
|
||||
input: UpdateClubProfileInput
|
||||
): Promise<void> {
|
||||
if (!callerUid) {
|
||||
throw new Error('Authentication required.');
|
||||
}
|
||||
|
||||
const name = (input.name ?? '').trim();
|
||||
if (name.length < 3 || name.length > 50) {
|
||||
throw new Error('Club name must be between 3 and 50 characters.');
|
||||
}
|
||||
|
||||
const description = (input.description ?? '').trim();
|
||||
if (description.length > 500) {
|
||||
throw new Error('Club description must not exceed 500 characters.');
|
||||
}
|
||||
|
||||
const homeRegion = (input.homeRegion ?? '').trim();
|
||||
if (homeRegion.length > 100) {
|
||||
throw new Error('Home region must not exceed 100 characters.');
|
||||
}
|
||||
|
||||
const motorcycleFocus = (input.motorcycleFocus ?? '').trim();
|
||||
if (motorcycleFocus.length > 100) {
|
||||
throw new Error('Motorcycle focus must not exceed 100 characters.');
|
||||
}
|
||||
|
||||
const clubRef = doc(firestore, 'clubs', clubId);
|
||||
const clubSnap = await getDoc(clubRef);
|
||||
if (!clubSnap.exists()) {
|
||||
throw new Error('Club not found.');
|
||||
}
|
||||
|
||||
const cd = clubSnap.data();
|
||||
if (cd.archivedAt) {
|
||||
throw new Error('Archived clubs cannot be modified.');
|
||||
}
|
||||
|
||||
let isAuthorized = cd.ownerUid === callerUid;
|
||||
if (!isAuthorized) {
|
||||
try {
|
||||
const memberSnap = await getDoc(doc(firestore, 'clubs', clubId, 'members', callerUid));
|
||||
if (memberSnap.exists()) {
|
||||
const m = memberSnap.data();
|
||||
if (m.status === 'active' && (m.role === 'owner' || m.role === 'admin')) {
|
||||
isAuthorized = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unauthorized
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
throw new Error('Permission denied. Only club owners and administrators can edit club profile details.');
|
||||
}
|
||||
|
||||
// Strict whitelist update
|
||||
await updateDoc(clubRef, {
|
||||
name,
|
||||
description,
|
||||
homeRegion,
|
||||
motorcycleFocus,
|
||||
updatedAt: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,579 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
|
||||
// Transpile TypeScript modules on the fly
|
||||
function loadTsModule(relativePath) {
|
||||
const fullPath = path.resolve(__dirname, relativePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
const tsCode = fs.readFileSync(fullPath, 'utf8');
|
||||
const jsCode = ts.transpileModule(tsCode, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
}).outputText;
|
||||
|
||||
const m = { exports: {} };
|
||||
const customRequire = (specifier) => {
|
||||
if (specifier === './firebase-client' || specifier.includes('firebase-client')) {
|
||||
return { firestore: {}, firebaseAuth: { currentUser: null, authStateReady: async () => {} } };
|
||||
}
|
||||
if (specifier === 'firebase/firestore') {
|
||||
return {
|
||||
collection: () => ({}),
|
||||
doc: (_db, ...parts) => ({ path: parts.join('/') }),
|
||||
getDoc: async () => ({ exists: () => false, data: () => ({}) }),
|
||||
getDocs: async () => ({ docs: [] }),
|
||||
query: () => ({}),
|
||||
where: () => ({}),
|
||||
limit: () => ({}),
|
||||
collectionGroup: () => ({}),
|
||||
updateDoc: async () => {},
|
||||
serverTimestamp: () => new Date(),
|
||||
};
|
||||
}
|
||||
if (specifier.startsWith('.')) {
|
||||
const resolved = path.resolve(dir, specifier);
|
||||
if (fs.existsSync(resolved + '.ts')) return loadTsModule(path.relative(__dirname, resolved + '.ts'));
|
||||
if (fs.existsSync(resolved + '.tsx')) return loadTsModule(path.relative(__dirname, resolved + '.tsx'));
|
||||
if (fs.existsSync(resolved)) return require(resolved);
|
||||
}
|
||||
return require(specifier);
|
||||
};
|
||||
|
||||
const fn = new Function('require', 'exports', 'module', '__filename', '__dirname', jsCode);
|
||||
fn(customRequire, m.exports, m, fullPath, dir);
|
||||
return m.exports;
|
||||
}
|
||||
|
||||
console.log('--- STARTING FOUNDER PORTAL TEST SUITE ---');
|
||||
|
||||
// 1. Load domain logic & fixtures
|
||||
const clubData = loadTsModule('../lib/club-data.ts');
|
||||
const clubFixtures = loadTsModule('../lib/club-fixtures.ts');
|
||||
const {
|
||||
getPortalPermissions,
|
||||
sanitizePortalError,
|
||||
fetchClubData,
|
||||
fetchManageableClubs,
|
||||
fetchFounderClubContext,
|
||||
updateClubProfile,
|
||||
} = clubData;
|
||||
const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures;
|
||||
|
||||
// =========================================================================
|
||||
// TEST 1: Role-Based Portal Access (Owner vs Admin vs Member vs Unauthenticated)
|
||||
// =========================================================================
|
||||
console.log('Testing: Role-based portal access...');
|
||||
|
||||
// Unauthenticated
|
||||
const unauthPerms = getPortalPermissions('member', false);
|
||||
assert.equal(unauthPerms.isAuthenticated, false);
|
||||
assert.equal(unauthPerms.canManageSettings, false);
|
||||
assert.equal(unauthPerms.canInviteMembers, false);
|
||||
assert.equal(unauthPerms.canApproveApplications, false);
|
||||
assert.equal(unauthPerms.canPlanRides, false);
|
||||
assert.equal(unauthPerms.canTransferOwnership, false);
|
||||
|
||||
// Authenticated Member (Standard non-admin rider)
|
||||
const memberPerms = getPortalPermissions('member', true);
|
||||
assert.equal(memberPerms.isAuthenticated, true);
|
||||
assert.equal(memberPerms.role, 'member');
|
||||
assert.equal(memberPerms.canManageSettings, false, 'Member must not manage settings');
|
||||
assert.equal(memberPerms.canInviteMembers, false, 'Member must not invite members');
|
||||
assert.equal(memberPerms.canApproveApplications, false, 'Member must not approve applications');
|
||||
assert.equal(memberPerms.canPlanRides, false, 'Member must not plan club rides');
|
||||
assert.equal(memberPerms.canTransferOwnership, false, 'Member must not transfer ownership');
|
||||
|
||||
// Authenticated Admin
|
||||
const adminPerms = getPortalPermissions('admin', true);
|
||||
assert.equal(adminPerms.isAuthenticated, true);
|
||||
assert.equal(adminPerms.role, 'admin');
|
||||
assert.equal(adminPerms.canManageSettings, true, 'Admin can manage general settings');
|
||||
assert.equal(adminPerms.canInviteMembers, true, 'Admin can invite members');
|
||||
assert.equal(adminPerms.canApproveApplications, true, 'Admin can approve applications');
|
||||
assert.equal(adminPerms.canPlanRides, true, 'Admin can plan club rides');
|
||||
assert.equal(adminPerms.canTransferOwnership, false, 'Admin MUST NOT transfer ownership');
|
||||
|
||||
// Authenticated Owner
|
||||
const ownerPerms = getPortalPermissions('owner', true);
|
||||
assert.equal(ownerPerms.isAuthenticated, true);
|
||||
assert.equal(ownerPerms.role, 'owner');
|
||||
assert.equal(ownerPerms.canManageSettings, true);
|
||||
assert.equal(ownerPerms.canInviteMembers, true);
|
||||
assert.equal(ownerPerms.canApproveApplications, true);
|
||||
assert.equal(ownerPerms.canPlanRides, true);
|
||||
assert.equal(ownerPerms.canTransferOwnership, true, 'Only Owner can transfer ownership');
|
||||
|
||||
console.log(' PASS: Role-based permissions strictly enforced (Owner, Admin, Member, Unauth).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 2: Club Display Rank != Administrative Role (Rank never grants access)
|
||||
// =========================================================================
|
||||
console.log('Testing: Club Rank does not escalate software permissions...');
|
||||
// If a member has Display Rank 'President' but software role 'member':
|
||||
const spoofedRankPerms = getPortalPermissions('member', true);
|
||||
assert.equal(spoofedRankPerms.canApproveApplications, false);
|
||||
assert.equal(spoofedRankPerms.canTransferOwnership, false);
|
||||
console.log(' PASS: Club Rank is purely display/organizational; software permissions derive only from role.');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 3: Safe Error Sanitization (No raw backend exception leakage)
|
||||
// =========================================================================
|
||||
console.log('Testing: Safe error sanitization & leak prevention...');
|
||||
|
||||
const leaks = [
|
||||
'FirebaseException: [cloud_firestore/permission-denied] Missing or insufficient permissions at /databases/(default)/documents/clubs/ms-atl-01',
|
||||
'FAILED_PRECONDITION: The query requires an index.',
|
||||
'Error: connect ECONNREFUSED 127.0.0.1:8080 at TCPConnectWrap.afterConnect [as oncomplete]',
|
||||
'DocumentNotFoundException: document /databases/(default)/documents/clubs/unknown was not found',
|
||||
'TypeError: Cannot read property uid of undefined at club_service.dart:142',
|
||||
];
|
||||
|
||||
for (const rawLeak of leaks) {
|
||||
const sanitized = sanitizePortalError(rawLeak);
|
||||
assert(!sanitized.includes('FirebaseException'), `Leaked FirebaseException in: ${sanitized}`);
|
||||
assert(!sanitized.includes('FAILED_PRECONDITION'), `Leaked FAILED_PRECONDITION in: ${sanitized}`);
|
||||
assert(!sanitized.includes('/databases/'), `Leaked Firestore path in: ${sanitized}`);
|
||||
assert(!sanitized.includes('127.0.0.1'), `Leaked network internals in: ${sanitized}`);
|
||||
assert(!sanitized.includes('.dart:'), `Leaked stack trace in: ${sanitized}`);
|
||||
assert(
|
||||
sanitized === 'Club information is restricted or requires authentication.' ||
|
||||
sanitized === "Club information couldn't be loaded. Check your network connection." ||
|
||||
sanitized === 'Club information could not be found.' ||
|
||||
sanitized === 'Something went wrong. Please try again.',
|
||||
`Unexpected sanitized message: ${sanitized}`
|
||||
);
|
||||
}
|
||||
console.log(' PASS: Zero raw backend exception or Firestore path leakage.');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 4: No Silent Production Demo Fallbacks
|
||||
// =========================================================================
|
||||
async function runAllTests() {
|
||||
console.log('Testing: No silent production demo fallbacks...');
|
||||
let threw = false;
|
||||
try {
|
||||
await fetchClubData('nonexistent-club-id');
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
assert.equal(err.message, 'Club information could not be found.');
|
||||
}
|
||||
assert.equal(threw, true, 'fetchClubData must throw not-found instead of returning demo club');
|
||||
console.log(' PASS: Production runtime strictly refuses silent demo fallbacks.');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 5: ClubMember != GroupRideParticipant Invariant & Location Privacy
|
||||
// =========================================================================
|
||||
console.log('Testing: ClubMember != GroupRideParticipant & Location Privacy Invariant...');
|
||||
|
||||
// Read ClubRideCard source to verify exact verbatim notice text
|
||||
const rideCardSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/club-ride-card.tsx'), 'utf8');
|
||||
|
||||
const verbatimNotice = 'Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.';
|
||||
assert(rideCardSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in ClubRideCard');
|
||||
|
||||
// Verify that forbidden claims are NOT made in ClubRideCard
|
||||
assert(!rideCardSrc.includes('end-to-end encryption'), 'ClubRideCard must not falsely claim end-to-end encryption');
|
||||
assert(!rideCardSrc.includes('RTK'), 'ClubRideCard must not falsely claim RTK');
|
||||
assert(!rideCardSrc.includes('mesh networking'), 'ClubRideCard must not falsely claim mesh networking');
|
||||
|
||||
console.log(' PASS: Verbatim live location privacy notice verified; no unverified claims present.');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 6: Club Mileage Privacy Behavior & Verified Badge Removal
|
||||
// =========================================================================
|
||||
console.log('Testing: Club Miles privacy behavior & badge hardening...');
|
||||
|
||||
const metricCardSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/metric-card.tsx'), 'utf8');
|
||||
const mileageDisclaimer = 'Calculated from eligible completed Club Rides. Private rider mileage is not automatically included.';
|
||||
assert(metricCardSrc.includes(mileageDisclaimer), 'Missing mandatory Club Miles privacy disclaimer');
|
||||
assert(metricCardSrc.includes('Completed Club Rides only'), 'Missing "Completed Club Rides only" badge text');
|
||||
assert(!metricCardSrc.includes('VERIFIED'), 'MetricCard must NOT contain misleading "VERIFIED" badge');
|
||||
|
||||
console.log(' PASS: Club Miles strict privacy semantics enforced (eligible completed rides only, no false VERIFIED badge).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 7: Roles & Ranks Architecture Note & No Fabricated Numbers
|
||||
// =========================================================================
|
||||
console.log('Testing: Roles & Ranks separation architecture note...');
|
||||
|
||||
const rolesRanksSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/roles-and-ranks-summary.tsx'), 'utf8');
|
||||
const verbatimRbacNote = 'Administrative roles determine software permissions. Club ranks are organizational titles and do not grant additional access.';
|
||||
assert(rolesRanksSrc.includes(verbatimRbacNote), 'Missing verbatim RBAC architecture note');
|
||||
assert(rolesRanksSrc.includes('ADMINISTRATIVE ROLES (Software Permissions)'), 'Missing software permissions section');
|
||||
assert(rolesRanksSrc.includes('CLUB RANKS (ORGANIZATIONAL TITLES)'), 'Missing organizational titles section');
|
||||
assert(!rolesRanksSrc.includes('106 Members'), 'RolesAndRanksSummary must not contain hardcoded 106 Members fabrication');
|
||||
|
||||
console.log(' PASS: Roles vs Ranks separation and verbatim architecture note verified (no fabricated counts).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 8: Top Bar Dev-Gating
|
||||
// =========================================================================
|
||||
console.log('Testing: Top Bar role preview switcher dev-gating...');
|
||||
|
||||
const topBarSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-top-bar.tsx'), 'utf8');
|
||||
assert(topBarSrc.includes("process.env.NODE_ENV === 'development'"), 'Top bar role preview must be dev-gated');
|
||||
|
||||
console.log(' PASS: Top Bar role preview switcher strictly dev-gated.');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 9: Dynamic /clubs/manage Discovery & Page Guards
|
||||
// =========================================================================
|
||||
console.log('Testing: /clubs/manage discovery route & access guards...');
|
||||
|
||||
const manageDiscoverySrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/manage/page.tsx'), 'utf8');
|
||||
assert(!manageDiscoverySrc.includes("redirect('/clubs/motorsignal-atlanta/manage')"), 'Must NOT have static redirect to Atlanta');
|
||||
assert(manageDiscoverySrc.includes('fetchManageableClubs'), 'Must discover manageable clubs via fetchManageableClubs');
|
||||
assert(manageDiscoverySrc.includes('No Clubs to Manage'), 'Must render "No Clubs to Manage" empty state');
|
||||
assert(manageDiscoverySrc.includes('Select Club to Manage'), 'Must render Club Selector for >1 clubs');
|
||||
|
||||
// FP-1.2: Administrative Role Terminology (Owner, Admin, Member - NEVER Officer)
|
||||
assert(manageDiscoverySrc.includes("c.role === 'owner' ? 'Owner' : 'Admin'"), 'Manageable-Club selector must map admin role to "Admin", NOT "Officer"');
|
||||
assert(!manageDiscoverySrc.includes("? 'Owner' : 'Officer'"), 'Manageable-Club selector must NEVER map admin role to "Officer"');
|
||||
assert(!manageDiscoverySrc.includes("Owner or Officer"), 'Manageable-Club selector copy must say Owner or Admin, never Owner or Officer');
|
||||
|
||||
const managePageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/page.tsx'), 'utf8');
|
||||
assert(managePageSrc.includes('Management Access Restricted'), 'Must guard against non-admin member access');
|
||||
|
||||
const clubsPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/page.tsx'), 'utf8');
|
||||
assert(clubsPageSrc.includes('href="/clubs/manage"'), 'Missing link to /clubs/manage on /clubs page');
|
||||
|
||||
console.log(' PASS: /clubs/manage dynamic discovery, role terminology (Owner/Admin/Member), and access control verified.');
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// TEST 10: Responsive Layout Breakpoints
|
||||
// =========================================================================
|
||||
console.log('Testing: Responsive layout classes across 1440, 1024, 768, and mobile...');
|
||||
|
||||
const shellSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-portal-shell.tsx'), 'utf8');
|
||||
const sidebarSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-sidebar.tsx'), 'utf8');
|
||||
|
||||
// Desktop persistent sidebar: lg:pl-72, lg:translate-x-0, max-w-[1440px]
|
||||
assert(shellSrc.includes('lg:pl-72'), 'Missing lg:pl-72 desktop sidebar offset');
|
||||
assert(shellSrc.includes('max-w-[1440px]'), 'Missing max-w-[1440px] desktop canvas container');
|
||||
assert(sidebarSrc.includes('w-72'), 'Missing w-72 persistent sidebar width');
|
||||
|
||||
// Mobile drawer: translate-x-full lg:translate-x-0, touch targets
|
||||
assert(sidebarSrc.includes('-translate-x-full lg:translate-x-0'), 'Missing mobile drawer translation classes');
|
||||
assert(sidebarSrc.includes('fixed left-0 top-0 h-full'), 'Missing fixed sidebar drawer layout');
|
||||
|
||||
console.log(' PASS: Responsive shell verified for 1440px desktop, 1024px tablet, and mobile off-canvas drawer.');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 11: FP-1.3 Founder Identity Contract
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-1.3 Founder Identity Contract (/public_profiles point-read & conservative fallback)...');
|
||||
|
||||
const clubDataSrc = fs.readFileSync(path.resolve(__dirname, '../lib/club-data.ts'), 'utf8');
|
||||
const typesSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/types.ts'), 'utf8');
|
||||
|
||||
// Verify ownerUid is documented as ownership identity only, not display name
|
||||
assert(typesSrc.includes('Identifies ownership identity only'), 'Club.ownerUid must document ownership identity only');
|
||||
assert(typesSrc.includes('Canonical profile resolution: /public_profiles/{ownerUid}'), 'Club must document profile resolution path');
|
||||
assert(typesSrc.includes("Never persist a redundant 'founderName' field on /clubs/{clubId}"), 'Club must forbid redundant founderName in schema');
|
||||
|
||||
// Verify club-data.ts point-reads public_profiles/{ownerUid}
|
||||
assert(clubDataSrc.includes("doc(firestore, 'public_profiles', d.ownerUid)"), 'fetchClubData must perform point read to public_profiles/{ownerUid}');
|
||||
assert(clubDataSrc.includes("founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder'"), 'Must maintain conservative fallback if profile not resolved');
|
||||
|
||||
console.log(' PASS: Founder identity contract verified (ownerUid is identity only; profile read via /public_profiles/{ownerUid}; conservative fallback).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 12: FP-1.3 Club Ride Association Contract (GroupRide.clubId == club.id)
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-1.3 Canonical Club Ride Association (GroupRide.clubId == club.id)...');
|
||||
|
||||
// Verify canonical query in club-data.ts
|
||||
assert(clubDataSrc.includes("where('clubId', '==', resolvedClubId)"), 'Upcoming rides must query strictly where clubId == resolvedClubId');
|
||||
assert(typesSrc.includes('Authoritative link: GroupRide.clubId == club.id'), 'Upcoming rides type must document canonical link');
|
||||
assert(typesSrc.includes('Affiliation is NEVER inferred from accepted rosters'), 'Must document non-inference from rosters');
|
||||
assert(typesSrc.includes('ClubMember != GroupRideParticipant'), 'Must document ClubMember != GroupRideParticipant invariant');
|
||||
|
||||
console.log(' PASS: Club Ride Association strictly bound to GroupRide.clubId == club.id (never inferred from rosters/accessUids).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 13: FP-1.3 Route Distance & Duration Storage Contract
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-1.3 Route Distance & Duration Storage Contract...');
|
||||
|
||||
// Verify types.ts documents persisted straightLineDistance vs optional Valhalla road estimates
|
||||
assert(typesSrc.includes('straightLineDistanceMeters: Geodesic Haversine distance between ordered stops (persisted)'), 'Must document straightLineDistanceMeters contract');
|
||||
assert(typesSrc.includes('estimatedDistanceMeters & estimatedDurationSeconds: Optional Valhalla road estimates'), 'Must document optional road estimates contract');
|
||||
|
||||
// Verify club-ride-card renders '--' when metrics are unpopulated
|
||||
assert(rideCardSrc.includes("ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'"), 'ClubRideCard must render -- when distance is unpopulated');
|
||||
assert(rideCardSrc.includes("ride.durationString ?? '--'"), 'ClubRideCard must render -- when duration is unpopulated');
|
||||
|
||||
console.log(' PASS: Route distance & duration contract verified (persisted straight-line vs optional road estimates; fallback to --).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 14 (Test A): FP-13 Profile Authorization Guard
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13 Test A - Profile Authorization Guard...');
|
||||
|
||||
// Owner and Admin allowed
|
||||
const ownerPerms14 = getPortalPermissions('owner', true);
|
||||
assert.equal(ownerPerms14.canManageSettings, true, 'Owner must have canManageSettings');
|
||||
const adminPerms14 = getPortalPermissions('admin', true);
|
||||
assert.equal(adminPerms14.canManageSettings, true, 'Admin must have canManageSettings');
|
||||
|
||||
// Member (non-admin) forbidden
|
||||
const memberPerms14 = getPortalPermissions('member', true);
|
||||
assert.equal(memberPerms14.canManageSettings, false, 'Member must NOT have canManageSettings');
|
||||
|
||||
// Unauthenticated forbidden
|
||||
const unauthPerms14 = getPortalPermissions('member', false);
|
||||
assert.equal(unauthPerms14.isAuthenticated, false, 'Unauth must not be authenticated');
|
||||
assert.equal(unauthPerms14.canManageSettings, false, 'Unauth must NOT have canManageSettings');
|
||||
|
||||
// Verify page component inspects auth and permissions
|
||||
const profilePageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/profile/page.tsx'), 'utf8');
|
||||
assert(profilePageSrc.includes('!isDemoPreview && !currentUser'), 'Profile page must check authentication');
|
||||
assert(profilePageSrc.includes('title="Authentication Required"'), 'Profile page must render Authentication Required error');
|
||||
assert(profilePageSrc.includes("router.push('/login')"), 'Profile page must direct unauthenticated users to /login');
|
||||
assert(profilePageSrc.includes('!isDemoPreview && !ctx.permissions.canManageSettings'), 'Profile page must check canManageSettings permission');
|
||||
assert(profilePageSrc.includes('title="Management Access Restricted"'), 'Profile page must render Management Access Restricted error');
|
||||
|
||||
console.log(' PASS: Test A verified (Owner/Admin allowed; Member forbidden; Unauthenticated rejected with SafeErrorState -> /login).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 15 (Test B): FP-13 Profile Query Gate (O(1) Point-Read Only)
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13 Test B - Profile Query Gate (O(1) Point-Read Only)...');
|
||||
|
||||
// Extract fetchFounderClubContext body from club-data.ts source
|
||||
const fetchCtxMatch = clubDataSrc.match(/export async function fetchFounderClubContext[\s\S]*?^}/m);
|
||||
assert(fetchCtxMatch, 'fetchFounderClubContext must exist in club-data.ts');
|
||||
const fetchCtxBody = fetchCtxMatch[0];
|
||||
|
||||
// Forbid collection queries in profile context
|
||||
assert(!fetchCtxBody.includes('group_rides'), 'fetchFounderClubContext must NOT query group_rides');
|
||||
assert(!fetchCtxBody.includes('club_applications'), 'fetchFounderClubContext must NOT query club_applications');
|
||||
assert(!fetchCtxBody.includes('collectionGroup'), 'fetchFounderClubContext must NOT perform collectionGroup queries');
|
||||
assert(!fetchCtxBody.includes('getDocs('), 'fetchFounderClubContext must NOT call getDocs (strictly point-reads getDoc only)');
|
||||
|
||||
console.log(' PASS: Test B verified (Zero collection queries; zero group_rides; zero club_applications; strictly O(1) point-reads).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 16 (Test C): FP-13 Profile Field Mutability & Whitelist Enforcement
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13 Test C - Profile Field Mutability & Whitelist Enforcement...');
|
||||
|
||||
// Test input validations via updateClubProfile
|
||||
await assert.rejects(
|
||||
async () => updateClubProfile('test-club', '', { name: 'Valid Name', description: '', homeRegion: '', motorcycleFocus: '' }),
|
||||
/Authentication required/,
|
||||
'Must require callerUid'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => updateClubProfile('test-club', 'uid-1', { name: 'ab', description: '', homeRegion: '', motorcycleFocus: '' }),
|
||||
/Club name must be between 3 and 50 characters/,
|
||||
'Must reject name shorter than 3 chars'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => updateClubProfile('test-club', 'uid-1', { name: 'A'.repeat(51), description: '', homeRegion: '', motorcycleFocus: '' }),
|
||||
/Club name must be between 3 and 50 characters/,
|
||||
'Must reject name longer than 50 chars'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => updateClubProfile('test-club', 'uid-1', { name: 'Valid Club', description: 'A'.repeat(501), homeRegion: '', motorcycleFocus: '' }),
|
||||
/Club description must not exceed 500 characters/,
|
||||
'Must reject description longer than 500 chars'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => updateClubProfile('test-club', 'uid-1', { name: 'Valid Club', description: '', homeRegion: 'A'.repeat(101), motorcycleFocus: '' }),
|
||||
/Home region must not exceed 100 characters/,
|
||||
'Must reject homeRegion longer than 100 chars'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
async () => updateClubProfile('test-club', 'uid-1', { name: 'Valid Club', description: '', homeRegion: '', motorcycleFocus: 'A'.repeat(101) }),
|
||||
/Motorcycle focus must not exceed 100 characters/,
|
||||
'Must reject motorcycleFocus longer than 100 chars'
|
||||
);
|
||||
|
||||
// Verify updateClubProfile implementation in club-data.ts strictly writes whitelisted fields
|
||||
const updateMatch = clubDataSrc.match(/export async function updateClubProfile[\s\S]*?^}/m);
|
||||
assert(updateMatch, 'updateClubProfile must exist in club-data.ts');
|
||||
const updateBody = updateMatch[0];
|
||||
|
||||
assert(updateBody.includes('updateDoc(clubRef, {'), 'updateClubProfile must call updateDoc');
|
||||
assert(!updateBody.includes('slug:'), 'updateClubProfile MUST NOT write slug');
|
||||
assert(!updateBody.includes('visibility:'), 'updateClubProfile MUST NOT write visibility');
|
||||
assert(!updateBody.includes('admissionPolicy:'), 'updateClubProfile MUST NOT write admissionPolicy');
|
||||
assert(!updateBody.includes('ownerUid:'), 'updateClubProfile MUST NOT write ownerUid');
|
||||
assert(!updateBody.includes('memberCount:'), 'updateClubProfile MUST NOT write memberCount');
|
||||
assert(!updateBody.includes('archivedAt:'), 'updateClubProfile MUST NOT write archivedAt');
|
||||
|
||||
console.log(' PASS: Test C verified (Strict input length bounds, whitelisted update, forbidden fields rejected).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 17 (Test D): FP-13 Development Preview Fixture Safety
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13 Test D - Development Preview Fixture Safety...');
|
||||
|
||||
// In development with allowDemoPreview: true -> returns fixture
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const demoCtx = await fetchFounderClubContext('any-id', undefined, { allowDemoPreview: true });
|
||||
assert.equal(demoCtx.isDemoFixture, true, 'Must return isDemoFixture: true when opt-in in development');
|
||||
assert.equal(demoCtx.club.name, FIXTURE_FOUNDER_CLUB.name);
|
||||
|
||||
// In production with allowDemoPreview: true -> fixture MUST NOT be returned
|
||||
process.env.NODE_ENV = 'production';
|
||||
let prodThrew = false;
|
||||
try {
|
||||
await fetchFounderClubContext('non-existent-club-id', undefined, { allowDemoPreview: true });
|
||||
} catch {
|
||||
prodThrew = true;
|
||||
}
|
||||
assert.equal(prodThrew, true, 'Fixture MUST NEVER be returned in production, even if allowDemoPreview is set');
|
||||
|
||||
process.env.NODE_ENV = prevEnv;
|
||||
|
||||
console.log(' PASS: Test D verified (Fixture returned strictly in development with explicit allowDemoPreview opt-in; never in production).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 18 (Test E): FP-13 Shared Sub-Routing & Active Navigation State
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13 Test E - Shared Sub-Routing & Active Navigation State...');
|
||||
|
||||
// Verify route links exist in FounderSidebar
|
||||
assert(sidebarSrc.includes('getRouteHref'), 'FounderSidebar must include getRouteHref');
|
||||
assert(sidebarSrc.includes('/manage/profile'), 'FounderSidebar must route to /manage/profile');
|
||||
assert(sidebarSrc.includes('/manage'), 'FounderSidebar must route to /manage');
|
||||
|
||||
// Verify exact active styling for profile
|
||||
assert(sidebarSrc.includes('bg-surface-container-high text-primary-container border-l-2 border-primary-container font-medium'), 'Profile item must have active highlight border and background');
|
||||
|
||||
// Verify profile page passes activeNav="profile"
|
||||
assert(profilePageSrc.includes('activeNav="profile"'), 'Profile page must pass activeNav="profile" to FounderPortalShell');
|
||||
assert(profilePageSrc.includes('<FounderPortalShell'), 'Profile page must reuse FounderPortalShell (not duplicate shell)');
|
||||
|
||||
console.log(' PASS: Test E verified (Shared sub-routing, activeNav="profile", shell reuse without duplication).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 19 (Test F): FP-13 Privacy Boundary Contract
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13 Test F - Privacy Boundary Contract...');
|
||||
|
||||
const editorSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-profile-editor.tsx'), 'utf8');
|
||||
|
||||
// Verify verbatim privacy contract statement
|
||||
assert(editorSrc.includes('Public Club Profile is organizational identity only. Member telemetry, GPS tracks, personal ride logs, and private account data are not included.'), 'Editor must contain canonical Privacy Contract statement');
|
||||
assert(editorSrc.includes('PRIVACY CONTRACT'), 'Editor must have PRIVACY CONTRACT section');
|
||||
|
||||
// Verify zero telemetry leakage in editor
|
||||
assert(!editorSrc.includes('leanAngle'), 'Profile editor must not reference telemetry leanAngle');
|
||||
assert(!editorSrc.includes('speedMph'), 'Profile editor must not reference telemetry speedMph');
|
||||
assert(!editorSrc.includes('gForce'), 'Profile editor must not reference telemetry gForce');
|
||||
|
||||
console.log(' PASS: Test F verified (Privacy contract displayed, personal telemetry/GPS strictly isolated).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 20 (Test G): FP-13 Public Directory Preview Contract
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13 Test G - Public Directory Preview Contract...');
|
||||
|
||||
// Verify Directory Preview exists and renders identity
|
||||
assert(editorSrc.includes('DIRECTORY PREVIEW'), 'Editor must include DIRECTORY PREVIEW');
|
||||
assert(editorSrc.includes('LIVE SIMULATION'), 'Editor must display LIVE SIMULATION badge');
|
||||
assert(editorSrc.includes('@{club.slug}'), 'Directory preview must render club handle');
|
||||
assert(editorSrc.includes('initials'), 'Directory preview must render emblem initials');
|
||||
|
||||
// Verify ZERO aggregate metrics in directory preview
|
||||
const previewMatch = editorSrc.match(/CARD A: PUBLIC DIRECTORY PREVIEW[\s\S]*?CARD B: ACCESS/);
|
||||
assert(previewMatch, 'Card A Directory Preview section must be identifiable');
|
||||
const previewBody = previewMatch[0];
|
||||
|
||||
assert(!previewBody.includes('club.memberCount'), 'Directory Preview MUST NOT render member count');
|
||||
assert(!previewBody.includes('MEMBERS'), 'Directory Preview MUST NOT render MEMBERS metric');
|
||||
assert(!previewBody.includes('UPCOMING RIDES'), 'Directory Preview MUST NOT render UPCOMING RIDES metric');
|
||||
assert(!previewBody.includes('TOTAL MILES'), 'Directory Preview MUST NOT render TOTAL MILES metric');
|
||||
|
||||
console.log(' PASS: Test G verified (Directory preview renders organizational identity only; zero aggregate metrics).');
|
||||
|
||||
// =========================================================================
|
||||
// TEST 21: FP-13.1 Club Profile Hardening Suite (Tests A through J)
|
||||
// =========================================================================
|
||||
console.log('Testing: FP-13.1 Hardening Suite (Tests A - J)...');
|
||||
|
||||
// A. FounderPortalShell does not contain STATUS: NOMINAL or global metric footer
|
||||
assert(!shellSrc.includes('STATUS: NOMINAL'), 'FounderPortalShell must NOT contain STATUS: NOMINAL');
|
||||
assert(!shellSrc.includes('MEMBERS'), 'FounderPortalShell must NOT contain global MEMBERS metric');
|
||||
assert(!shellSrc.includes('UPCOMING RIDES'), 'FounderPortalShell must NOT contain global UPCOMING RIDES metric');
|
||||
assert(!shellSrc.includes('ACCESSIBLE ROUTES'), 'FounderPortalShell must NOT contain global ACCESSIBLE ROUTES metric');
|
||||
assert(!shellSrc.includes('<footer'), 'FounderPortalShell must NOT contain global operational status footer');
|
||||
|
||||
// B. Profile contains no unsupported verified badge
|
||||
assert(!editorSrc.includes('title="Verified Syndicate"'), 'Profile must NOT contain Verified Syndicate title');
|
||||
assert(!editorSrc.includes('verified'), 'Profile must NOT contain verified checkmark badge');
|
||||
|
||||
// C. Profile contains no FP-13 / MODULE implementation terminology
|
||||
assert(!editorSrc.includes('FP-13'), 'Profile must NOT contain FP-13 terminology');
|
||||
assert(!editorSrc.includes('FP-13.1'), 'Profile must NOT contain FP-13.1 terminology');
|
||||
assert(!editorSrc.includes('MODULE'), 'Profile must NOT contain MODULE terminology');
|
||||
assert(!editorSrc.includes('STAGE'), 'Profile must NOT contain STAGE terminology');
|
||||
|
||||
// D. Profile contains no internal Club document/registry ID
|
||||
assert(!editorSrc.includes('REGISTRY REF'), 'Profile must NOT contain REGISTRY REF');
|
||||
assert(!editorSrc.includes('club.id.slice'), 'Profile must NOT expose truncated Club document ID');
|
||||
|
||||
// E. Profile contains no unsupported media format/storage labels
|
||||
assert(!editorSrc.includes('VECTOR_BOUND'), 'Profile must NOT contain VECTOR_BOUND');
|
||||
assert(!editorSrc.includes('CANVAS // TELEMETRY DUSK'), 'Profile must NOT contain CANVAS // TELEMETRY DUSK');
|
||||
assert(!editorSrc.includes('512x512 PNG'), 'Profile must NOT contain 512x512 PNG requirement');
|
||||
assert(!editorSrc.includes('16:5 RATIO'), 'Profile must NOT contain 16:5 RATIO label');
|
||||
assert(!editorSrc.includes('lh3.googleusercontent.com'), 'Profile must NOT contain fabricated stock car photo');
|
||||
|
||||
// F. Directory Preview remains identity-only
|
||||
assert(!previewBody.includes('club.memberCount'), 'Directory Preview MUST NOT render member count');
|
||||
assert(!previewBody.includes('MEMBERS'), 'Directory Preview MUST NOT render MEMBERS metric');
|
||||
assert(!previewBody.includes('UPCOMING RIDES'), 'Directory Preview MUST NOT render UPCOMING RIDES metric');
|
||||
assert(!previewBody.includes('TOTAL MILES'), 'Directory Preview MUST NOT render TOTAL MILES metric');
|
||||
assert(!previewBody.includes('verified'), 'Directory Preview MUST NOT render verified badge');
|
||||
|
||||
// G. visibility/admission remain non-editable
|
||||
assert(!updateBody.includes('visibility:'), 'updateClubProfile MUST NOT write visibility');
|
||||
assert(!updateBody.includes('admissionPolicy:'), 'updateClubProfile MUST NOT write admissionPolicy');
|
||||
assert(!editorSrc.includes('name="visibility"'), 'Profile editor must NOT have visibility input');
|
||||
assert(!editorSrc.includes('name="admissionPolicy"'), 'Profile editor must NOT have admissionPolicy input');
|
||||
|
||||
// H. O(1) point-read contract unchanged
|
||||
assert(!fetchCtxBody.includes('group_rides'), 'fetchFounderClubContext must NOT query group_rides');
|
||||
assert(!fetchCtxBody.includes('club_applications'), 'fetchFounderClubContext must NOT query club_applications');
|
||||
assert(!fetchCtxBody.includes('collectionGroup'), 'fetchFounderClubContext must NOT perform collectionGroup queries');
|
||||
assert(!fetchCtxBody.includes('getDocs('), 'fetchFounderClubContext must NOT call getDocs');
|
||||
|
||||
// I. profile mutation whitelist unchanged
|
||||
assert(updateBody.includes('updateDoc(clubRef, {'), 'updateClubProfile must call updateDoc');
|
||||
assert(!updateBody.includes('slug:'), 'updateClubProfile MUST NOT write slug');
|
||||
assert(!updateBody.includes('ownerUid:'), 'updateClubProfile MUST NOT write ownerUid');
|
||||
assert(!updateBody.includes('memberCount:'), 'updateClubProfile MUST NOT write memberCount');
|
||||
assert(!updateBody.includes('archivedAt:'), 'updateClubProfile MUST NOT write archivedAt');
|
||||
|
||||
// J. production fixture isolation unchanged
|
||||
assert.equal(demoCtx.isDemoFixture, true, 'Must return isDemoFixture: true in development with opt-in');
|
||||
assert.equal(prodThrew, true, 'Fixture MUST NEVER be returned in production');
|
||||
|
||||
console.log(' PASS: FP-13.1 Tests A through J verified (Footer removed, verified badge removed, stage terms removed, internal ID removed, media labels removed, preview identity-only, non-editable visibility/admission, O(1) reads, whitelist mutation, fixture safety).');
|
||||
|
||||
console.log('--- ALL FOUNDER PORTAL TESTS PASSED (21/21) ---');
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
console.error('Test Suite Failure:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue