From 5eb47e8c09c792cdf257b199e51fad22a4f081f8 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 9 Sep 2026 07:06:45 -0400 Subject: [PATCH] feat: add founder club profile and sub-routing foundation --- .../clubs/[clubId]/manage/profile/page.tsx | 152 ++++ .../founder-portal/founder-portal-shell.tsx | 72 ++ .../founder-portal/founder-profile-editor.tsx | 552 +++++++++++++++ .../founder-portal/founder-sidebar.tsx | 280 ++++++++ .../founder-portal/founder-top-bar.tsx | 166 +++++ website/lib/club-data.ts | 667 ++++++++++++++++++ website/tests/founder-portal.test.cjs | 579 +++++++++++++++ 7 files changed, 2468 insertions(+) create mode 100644 website/app/clubs/[clubId]/manage/profile/page.tsx create mode 100644 website/components/founder-portal/founder-portal-shell.tsx create mode 100644 website/components/founder-portal/founder-profile-editor.tsx create mode 100644 website/components/founder-portal/founder-sidebar.tsx create mode 100644 website/components/founder-portal/founder-top-bar.tsx create mode 100644 website/lib/club-data.ts create mode 100644 website/tests/founder-portal.test.cjs diff --git a/website/app/clubs/[clubId]/manage/profile/page.tsx b/website/app/clubs/[clubId]/manage/profile/page.tsx new file mode 100644 index 0000000..8a06364 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/profile/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +
+ ); + } + + // Unauthenticated safe error state + if (unauthenticated) { + return ( +
+ router.push('/login')} + /> +
+ ); + } + + // Unauthorized (Member or non-admin) safe error state + if (unauthorized) { + return ( +
+ router.push('/clubs')} + /> +
+ ); + } + + // Error or missing context + if (error || !context) { + return ( +
+ router.push('/clubs')} + onRetry={loadData} + /> +
+ ); + } + + return ( + + { + setContext((prev) => + prev + ? { + ...prev, + club: { + ...prev.club, + ...updated, + }, + } + : null + ); + }} + /> + + ); +} diff --git a/website/components/founder-portal/founder-portal-shell.tsx b/website/components/founder-portal/founder-portal-shell.tsx new file mode 100644 index 0000000..7b5768b --- /dev/null +++ b/website/components/founder-portal/founder-portal-shell.tsx @@ -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 ( +
+ {/* Sidebar (Desktop persistent + Mobile off-canvas drawer) */} + setMobileSidebarOpen(false)} + /> + + {/* Main Layout Area */} +
+ {/* Top App Bar */} + setMobileSidebarOpen(!mobileSidebarOpen)} + onPlanRide={onPlanRide} + onPostAnnouncement={onPostAnnouncement} + onSwitchRole={onSwitchRole} + onSwitchClub={onSwitchClub} + /> + + {/* Content Body Container */} +
+ {children} +
+
+
+ ); +} diff --git a/website/components/founder-portal/founder-profile-editor.tsx b/website/components/founder-portal/founder-profile-editor.tsx new file mode 100644 index 0000000..5a9453a --- /dev/null +++ b/website/components/founder-portal/founder-profile-editor.tsx @@ -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) => 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(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 ( +
+ {/* 3. PAGE HEADER */} +
+
+
+ + PRECISION, NOT AGGRESSION + + + // {homeRegion ? `${homeRegion.toUpperCase()} ZONE` : 'REGIONAL SQUADRON'} + +
+

+ CLUB PROFILE +

+

+ Manage public-facing club identity, branding presentation, and discovery presence. +

+
+
+ + {/* 4. TOP 4 METRIC CARDS */} +
+ {/* Card 1: Club Name */} +
+
+ CLUB NAME + +
+
+
{name || club.name}
+
3-50 chars validated
+
+
+ + {/* Card 2: Home Region */} +
+
+ HOME REGION + location_on +
+
+
+ {homeRegion || 'Not specified'} +
+
Home Region
+
+
+ + {/* Card 3: Motorcycle Focus */} +
+
+ MOTORCYCLE FOCUS + sports_motorsports +
+
+
+ {motorcycleFocus || 'General'} +
+
Motorcycle Focus
+
+
+ + {/* Card 4: Discovery Presence */} +
+
+ DISCOVERY PRESENCE + +
+
+
+ {club.visibility} +
+
+ {club.visibility === 'public' + ? 'Listed in public club index & directory' + : club.visibility === 'unlisted' + ? 'Accessible via direct handle only' + : 'Restricted private club'} +
+
+
+
+ + {/* 5. MAIN WORKSPACE (12-Column Grid) */} +
+ {/* LEFT MAIN (8 cols) */} +
+ {/* Status Feedback Banner */} + {saveStatus === 'success' && ( +
+ check_circle +
+ Club profile changes have been saved successfully. +
+
+ )} + + {saveStatus === 'error' && errorMessage && ( +
+ error +
{errorMessage}
+
+ )} + + {/* SECTION 1: PUBLIC CLUB IDENTITY */} +
+
+
+ badge +

+ SECTION 1: PUBLIC CLUB IDENTITY +

+
+
+ + {/* Club Name */} +
+ +
+ 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" + /> + + {isNameValid ? 'VALID' : `${trimmedName.length}/50`} + +
+

+ 3-50 characters. Displayed on public discovery, ride manifests, and club roster. +

+
+ + {/* Club Handle / Identifier (Locked) */} +
+
+ + + IMMUTABLE + +
+
+ lock + +
+

+ Club handles are permanently reserved and cannot be renamed or reassigned. +

+
+ + {/* Club Description */} +
+
+ + + {description.length}/500 + +
+