diff --git a/website/app/clubs/[clubId]/manage/applications/page.tsx b/website/app/clubs/[clubId]/manage/applications/page.tsx new file mode 100644 index 0000000..b8e67e7 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/applications/page.tsx @@ -0,0 +1,139 @@ +'use client'; + +import React, { useEffect, useState, use } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + fetchFounderClubContext, + FounderClubContext, + sanitizePortalError, +} from '../../../../../lib/club-data'; +import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell'; +import { FounderApplications } from '../../../../../components/founder-portal/founder-applications'; +import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state'; +import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state'; +import { firebaseAuth } from '../../../../../lib/firebase-client'; + +interface PageProps { + params: Promise<{ clubId: string }>; +} + +export default function ClubFounderApplicationsPage({ params }: PageProps) { + const { clubId } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [context, setContext] = useState(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: Owner and Admin can access Applications; Members cannot + if (!isDemoPreview && (!ctx.permissions.canApproveApplications && !ctx.permissions.canManageSettings)) { + setUnauthorized(true); + setLoading(false); + return; + } + + setContext(ctx); + } catch (err) { + setError(sanitizePortalError(err, "Club applications couldn't be loaded.")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, [clubId]); + + if (loading) { + return ( +
+ +
+ ); + } + + // 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 ( + + + + ); +} diff --git a/website/app/clubs/[clubId]/manage/page.tsx b/website/app/clubs/[clubId]/manage/page.tsx new file mode 100644 index 0000000..51d5c7f --- /dev/null +++ b/website/app/clubs/[clubId]/manage/page.tsx @@ -0,0 +1,151 @@ +'use client'; + +import React, { useEffect, useState, use } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + Club, + ClubMetrics, + ClubUpcomingRide, + ClubMember, + ClubActivityItem, + ClubRole, + UserPortalPermissions, +} from '../../../../components/founder-portal/types'; +import { + fetchClubData, + getPortalPermissions, + sanitizePortalError, +} from '../../../../lib/club-data'; +import { FounderPortalShell } from '../../../../components/founder-portal/founder-portal-shell'; +import { FounderOverview } from '../../../../components/founder-portal/founder-overview'; +import { OverviewSkeleton } from '../../../../components/founder-portal/skeleton-state'; +import { SafeErrorState } from '../../../../components/founder-portal/safe-error-state'; +import { firebaseAuth } from '../../../../lib/firebase-client'; + +interface PageProps { + params: Promise<{ clubId: string }>; +} + +export default function ClubFounderPortalPage({ params }: PageProps) { + const { clubId } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [club, setClub] = useState(null); + const [metrics, setMetrics] = useState(null); + const [upcomingRides, setUpcomingRides] = useState([]); + const [pendingApplications, setPendingApplications] = useState([]); + const [activities, setActivities] = useState([]); + const [userRole, setUserRole] = useState('owner'); + const [userRank, setUserRank] = useState('President'); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeNav, setActiveNav] = useState('overview'); + + async function loadData() { + setLoading(true); + setError(null); + try { + await firebaseAuth.authStateReady(); + const currentUser = firebaseAuth.currentUser; + const isDemoPreview = searchParams?.get('preview') === 'demo'; + const data = await fetchClubData(clubId, currentUser?.uid, { + allowDemoPreview: isDemoPreview, + }); + setClub(data.club); + setMetrics(data.metrics); + setUpcomingRides(data.upcomingRides); + setPendingApplications(data.pendingApplications); + setActivities(data.activities); + setUserRole(data.userRole); + setUserRank(data.userRank); + } catch (err) { + setError(sanitizePortalError(err, "Club information couldn't be loaded.")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, [clubId]); + + const permissions: UserPortalPermissions = getPortalPermissions(userRole, true); + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + // Unauthenticated Guard + const isDemo = searchParams?.get('preview') === 'demo'; + if (!firebaseAuth.currentUser && !isDemo) { + return ( +
+ router.push(`/login?returnUrl=/clubs/${clubId}/manage`)} + /> +
+ ); + } + + if (error || !club || !metrics) { + return ( +
+ router.push('/clubs')} + onRetry={loadData} + /> +
+ ); + } + + // Member Access Guard: Non-admin members are forbidden from administrative portal + const isDev = process.env.NODE_ENV === 'development'; + if (!isDev && userRole !== 'owner' && userRole !== 'admin') { + return ( +
+ router.push('/clubs')} + /> +
+ ); + } + + return ( + setActiveNav(nav)} + onSwitchRole={(newRole: ClubRole) => setUserRole(newRole)} + pendingApplicationsCount={pendingApplications.length} + upcomingRidesCount={upcomingRides.length} + > + + + ); +} 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/app/clubs/[clubId]/manage/settings/page.tsx b/website/app/clubs/[clubId]/manage/settings/page.tsx new file mode 100644 index 0000000..4ca7957 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/settings/page.tsx @@ -0,0 +1,158 @@ +'use client'; + +import React, { useEffect, useState, use } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + fetchFounderClubContext, + FounderClubContext, + sanitizePortalError, +} from '../../../../../lib/club-data'; +import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell'; +import { FounderSettingsEditor } from '../../../../../components/founder-portal/founder-settings-editor'; +import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state'; +import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state'; +import { firebaseAuth } from '../../../../../lib/firebase-client'; + +interface PageProps { + params: Promise<{ clubId: string }>; +} + +export default function ClubFounderSettingsPage({ params }: PageProps) { + const { clubId } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [context, setContext] = useState(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: Owner and Admin can access Settings; Members cannot + if (!isDemoPreview && !ctx.permissions.canManageSettings) { + setUnauthorized(true); + setLoading(false); + return; + } + + setContext(ctx); + } catch (err) { + setError(sanitizePortalError(err, "Club settings couldn't be loaded.")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, [clubId]); + + if (loading) { + return ( +
+ +
+ ); + } + + // 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) => { + if (!prev) return null; + const isFormerOwner = updated.ownerUid && updated.ownerUid !== prev.club.ownerUid; + return { + ...prev, + club: { + ...prev.club, + ...updated, + }, + currentUser: isFormerOwner + ? { ...prev.currentUser, role: 'admin', displayRank: 'Admin' } + : prev.currentUser, + permissions: isFormerOwner + ? { ...prev.permissions, role: 'admin', canTransferOwnership: false } + : prev.permissions, + }; + }); + }} + /> + + ); +} diff --git a/website/app/clubs/manage/page.tsx b/website/app/clubs/manage/page.tsx new file mode 100644 index 0000000..1a1c2b8 --- /dev/null +++ b/website/app/clubs/manage/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { onAuthStateChanged, User } from 'firebase/auth'; +import { firebaseAuth } from '../../../lib/firebase-client'; +import { fetchManageableClubs, ManageableClubSummary } from '../../../lib/club-data'; +import { OverviewSkeleton } from '../../../components/founder-portal/skeleton-state'; +import { SafeErrorState } from '../../../components/founder-portal/safe-error-state'; + +export default function ClubManageDiscoveryPage() { + const router = useRouter(); + const [currentUser, setCurrentUser] = useState(null); + const [authChecked, setAuthChecked] = useState(false); + const [loading, setLoading] = useState(true); + const [clubs, setClubs] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => { + setCurrentUser(user); + setAuthChecked(true); + }); + return () => unsubscribe(); + }, []); + + async function loadClubs(uid: string) { + setLoading(true); + setError(null); + try { + const results = await fetchManageableClubs(uid); + setClubs(results); + if (results.length === 1) { + // Automatically redirect to the single manageable club + const target = results[0].slug || results[0].id; + router.replace(`/clubs/${target}/manage`); + } + } catch { + setError("Unable to discover manageable clubs. Please check your network connection."); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (!authChecked) return; + if (currentUser) { + void loadClubs(currentUser.uid); + } else { + setLoading(false); + } + }, [authChecked, currentUser]); + + // Loading state (auth checking or discovering clubs) + if (!authChecked || loading) { + return ( +
+
+ +
+
+ ); + } + + // Unauthenticated state: prompt sign-in + if (!currentUser) { + return ( +
+
+
+ +
+ lock +
+ +
+
+ + Rider Authentication Required +
+

+ Sign In to Access Portal +

+

+ Founder Portal requires an active MotorSignal rider account with Club Owner or Admin administration permissions. +

+
+ +
+ + login + Sign In with Rider Account + + + + Create New Account + + + + Browse Public Motorcycle Clubs → + +
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+ loadClubs(currentUser.uid)} + /> +
+ ); + } + + // Single club redirecting transition + if (clubs.length === 1) { + return ( +
+
+
+

+ Redirecting to {clubs[0].name}... +

+
+
+ ); + } + + // 0 clubs: No clubs to manage + if (clubs.length === 0) { + return ( +
+
+
+ +
+ hub +
+ +
+ + Management Roster // 0 Clubs + +

+ No Clubs to Manage +

+

+ Your rider account doesn't currently have Owner or Admin permissions for any registered clubs. To manage a club, create a new club or request admin privileges from your club owner. +

+
+ +
+ + add_circle + Register Your Club + + + + explore + Browse Public Clubs + +
+
+
+ ); + } + + // >1 clubs: Clean Club Selector + return ( +
+
+ {/* Header */} +
+
+
+ + Founder Portal Cockpit +
+

+ Select Club to Manage +

+

+ Choose an active motorcycle club from your authorized administrative rosters. +

+
+ + + add + Register New Club + +
+ + {/* Club Cards Grid */} +
+ {clubs.map((c) => ( + +
+ +
+
+ sports_motorsports +
+ +
+
+

+ {c.name} +

+ + {c.role === 'owner' ? 'Owner' : 'Admin'} + +
+ +
+ {c.slug && /{c.slug}} + • + {c.memberCount} {c.memberCount === 1 ? 'member' : 'members'} + {c.homeRegion && ( + <> + • + {c.homeRegion} + + )} +
+
+
+ +
+ Manage Cockpit + + arrow_forward + +
+ + ))} +
+ + {/* Footer info */} +
+
+ shield + Only clubs where you are an active Owner or Admin appear here. +
+ + All Public Clubs → + +
+
+
+ ); +} + diff --git a/website/app/clubs/page.tsx b/website/app/clubs/page.tsx new file mode 100644 index 0000000..f243254 --- /dev/null +++ b/website/app/clubs/page.tsx @@ -0,0 +1,601 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; + +export default function ClubsPage() { + return ( +
+
+{/* Top Command Matrix Hero */} +
+
+
+
+
+
+
+ + + + +Motorcycle Clubs, Fleets & Track Academies +
+

+ Command your club fleet with military-grade telemetry. +

+

+ Empower road captains, track day organizers, and racing teams with central member rosters, telemetry leaderboards, safety beacon monitoring, and fleet maintenance. +

+ +
+
+Sync Rate +10Hz +Real-time CAN-bus +
+
+Active Fleets +1,840+ +Global syndicates +
+
+Safety Beacon +<400ms +Crash dispatch latency +
+
+
+
+
+
+
+ +Live Roster Stream +
+Net: 48 Node Mesh +
+
+
+
+
01
+
+
Cap. Marcus Vance
+
Yamaha YZF-R1M • Leader
+
+
+
+
54.2°
+
Apex Lean
+
+
+
+
+
02
+
+
Elena Rostova
+
Ducati Panigale V4 S
+
+
+
+
1:41.88
+
Sector 3 Split
+
+
+
+
+
03
+
+
Daisuke Sato
+
BMW S1000RR • Tail Marshal
+
+
+
+
99.4%
+
Tire Integrity
+
+
+
+
+
+cell_tower +Pack Formation: Diamond Alpha +
+Health: Nominal +
+
+
+
+
+
+{/* Key Operational Pillars Section */} +
+
+
+Tactical Capabilities +

Key Operational Pillars for Clubs

+

Standardize ride telemetry, protect group formations, and track hardware readiness across every connected machine.

+
+
+{/* Pillar 1 */} +
+
+
+
+groups +
+Pillar // 01 +
+

Central Fleet & Member Roster

+

+ Manage hundreds of club members, their registered bikes, emergency contact info, and marshal access permissions with instant profile synchronization. +

+
+
+
+Active Riders Validated +482 / 500 +
+
+
+
+
+
+{/* Pillar 2 */} +
+
+
+
+military_tech +
+Pillar // 02 +
+

Private Leaderboards & Segment Trophies

+

+ Compare verified lean angles, sector lap times, and track speed on private leaderboards with multi-constellation anti-cheat GPS validation. +

+
+
+
+verified +
+
Anti-Cheat Enforced
+
10Hz RTK-grade drift rejection
+
+
+Secure +
+
+{/* Pillar 3 */} +
+
+
+
+radar +
+Pillar // 03 +
+

Road Captain Master Console

+

+ Bird's-eye live view of all club pack rides with real-time health, incident warnings, high-G deceleration alerts, and direct marshal dispatch. +

+
+
+
+Dispatch Status +
3 Marshals Ready
+
+
+Incident Queue +
0 Critical
+
+
+
+{/* Pillar 4 */} +
+
+
+
+manufacturing +
+Pillar // 04 +
+

Fleet Maintenance & Engine Telemetry

+

+ Track oil life, brake wear estimates, tire heat cycles, and ECU fault codes across member bikes to prevent mechanical DNFs on track and road. +

+
+
+
+vital_signs +CAN-Bus ECU Diagnostics +
+100% Health Check +
+
+
+
+
+{/* Featured Verified Clubs Directory */} +
+
+
+
+Global Roster +

Featured Verified Clubs

+

Join elite performance racing syndicates and organized touring packs running live telemetry networks.

+
+
+Sort: +Verified High-Performance +
+
+
+{/* Club Card 1 */} +
+
+
+ + + 2 Active Rides + +CA-09 +
+
+

Apex Pro Track School

+

San Jose, CA

+
+
+240 Members +14 Track Days/yr +Pro Certified +
+

+ Lead Coach: Dave Kinlan (AMA Pro Superbike Alum). Specialized in telemetry-guided apex entry and throttle-trace optimization. +

+
+
+ +
+
+{/* Club Card 2 */} +
+
+
+ + + 4 Active Rides + +CA-01 +
+
+

NorCal Canyon Syndicate

+

San Francisco, CA

+
+
+580 Members +Weekly Packs +Sport / Superbike +
+

+ Road Captain: Ryan 'Hatch' Miller. Precision formation passes, mountain safety monitors, and private Skaggs Springs leaderboards. +

+
+
+ +
+
+{/* Club Card 3 */} +
+
+
+ + Standby + +WA-04 +
+
+

Pacific Northwest Moto Fleet

+

Seattle, WA

+
+
+310 Members +Touring & ADV +All-Weather +
+

+ Fleet Lead: Sarah Jenkins. Cascade loop expeditions, wet-grip telemetry tracking, and satellite mesh communicator integration. +

+
+
+ +
+
+{/* Club Card 4 */} +
+
+
+ + + 1 Active Ride + +TX-03 +
+
+

Austin Apex League

+

Austin, TX

+
+
+420 Members +Track & Street +COTA Track Days +
+

+ Founder: Mateo Alvarez. Regular COTA track allocations, night Hill Country loops, and high-frequency suspension setup telemetry. +

+
+
+ +
+
+
+
+
+{/* Live Telemetry Radar Preview Panel */} +
+
+
+
+
+Live Marshal Radar +

Keep pack formations tighter and track incidents instantly.

+

+ When a rider slows unexpectedly or triggers an emergency IMU deceleration event, the entire road captain council gets an immediate audio-visual alert with pinpoint GPS coordinates. +

+
+
+check_circle +Automatic peg-scrape hazard mapping for follow riders +
+
+check_circle +Anti-straggle pace delta monitoring on multi-tier routes +
+
+check_circle +Cross-brand CAN-bus support (Ducati, Yamaha, BMW, KTM, Aprilia) +
+
+
+
+
+
+Telemetry Delta // Sector 2 +Apex Cloud Active +
+{/* Telemetry Sparkline Simulation */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Max Speed +264 km/h +
+
+Peak Lean +57.8° L +
+
+G-Force Lat +1.42 G +
+
+
+
+
+
+
+
+{/* Club Administrative Tier Comparison */} +
+
+
+Organization Tiers +

Club Administrative Plans

+

Scalable telemetry, cloud computing, and real-time mesh for grassroots packs up to world-class racing academies.

+
+
+{/* Tier 1 */} +
+
+
+Entry Fleet +Community +
+

Grassroots Clubs

+
+Free +/ forever +
+

+ Essential fleet synchronization for local casual packs and weekend canyon riders. +

+
+
+check +Up to 15 riders per pack +
+
+check +Standard GPS live tracking +
+
+check +Basic lean angle logs +
+
+check +Public club directory listing +
+
+
+
+ +
+
+{/* Tier 2 (Highlighted) */} +
+
+ Most Popular Syndicate +
+
+
+Pro Formation +Verified Badge +
+

Verified Syndicate

+
+$49 +/ month +
+

+ Engineered for established sportbike clubs, track groups, and high-frequency touring units. +

+
+
+check_circle +Up to 100 verified riders +
+
+check_circle +Private telemetry leaderboards +
+
+check_circle +Road captain emergency marshal beacons +
+
+check_circle +10Hz high-frequency CAN-bus sync +
+
+check_circle +Automated crash & peg-scrape alerts +
+
+
+
+ +
+
+{/* Tier 3 */} +
+
+
+Enterprise Grid +FIM Data Spec +
+

Enterprise Academy

+
+$199 +/ month +
+

+ Complete professional infrastructure for track schools, race teams, and commercial fleets. +

+
+
+verified +Unlimited riders and fleet assets +
+
+verified +Live track telemetry broadcast link +
+
+verified +Telemetry REST API & Webhook access +
+
+verified +Custom club branding & sub-domain +
+
+verified +Dedicated fleet engineer support +
+
+
+
+ +
+
+
+
+
+{/* Interactive Club Registration Callout */} +
+
+
+
+Ready to Integrate? +

Connect your club to the MotorSignal Grid today.

+

Setup takes less than 3 minutes. Distribute invite tokens to your road marshals and member bikes.

+
+
+ +
+
+
+
+
+ +
+ ); +} diff --git a/website/components/founder-portal/attention-card.tsx b/website/components/founder-portal/attention-card.tsx new file mode 100644 index 0000000..449e994 --- /dev/null +++ b/website/components/founder-portal/attention-card.tsx @@ -0,0 +1,252 @@ +'use client'; + +import React from 'react'; +import { ClubMember, ClubUpcomingRide, UserPortalPermissions } from './types'; +import { EmptyState } from './empty-state'; + +interface AttentionSectionProps { + pendingApplications: ClubMember[]; + upcomingRides: ClubUpcomingRide[]; + permissions: UserPortalPermissions; + onApproveApplication?: (uid: string) => void; + onReviewApplication?: (uid: string) => void; + onAssignSweep?: (rideId: string) => void; + onMarkChecked?: () => void; +} + +export function AttentionSection({ + pendingApplications, + upcomingRides, + permissions, + onApproveApplication, + onReviewApplication, + onAssignSweep, + onMarkChecked, +}: AttentionSectionProps) { + // Find any ride needing assignment + const rideNeedingSweep = upcomingRides.find( + (r) => r.sweepStatus === 'needs_assignment' || r.sweepStatus === 'vacant' + ); + + const pendingAppsCount = pendingApplications.length; + const totalPendingActions = (pendingAppsCount > 0 ? 1 : 0) + (rideNeedingSweep ? 1 : 0); + + return ( +
+ {/* Header */} +
+
+ 0 ? 'bg-error animate-pulse' : 'bg-brand-cyan' + }`} + /> +

+ Attention Required +

+
+ +
+ + {totalPendingActions} pending {totalPendingActions === 1 ? 'action' : 'actions'} + + {onMarkChecked && ( + + )} +
+
+ + {/* 3 Triage Cards Grid */} +
+ {/* Card 1: Membership Applications */} +
+
+
+
+ + person_add + + + Membership Applications + +
+ {pendingAppsCount > 0 ? ( + + {pendingAppsCount} Pending + + ) : ( + 0 Pending + )} +
+ +

+ Membership applications awaiting review. +

+ + {pendingAppsCount === 0 ? ( + + ) : ( +
+ {pendingApplications.slice(0, 3).map((app) => ( +
+
+
+ {app.displayName ?? 'New Rider'} +
+
+ {app.requestedAt ?? app.joinedAt ?? 'Recently'} +
+
+ +
+ {permissions.canApproveApplications && ( + + )} + +
+
+ ))} +
+ )} +
+ +
+ Triage Status + {pendingAppsCount === 0 ? 'All Cleared' : `${pendingAppsCount} Actionable`} +
+
+ + {/* Card 2: Moderation Flag / Wall Posts (Preserving deferred backend architecture) */} +
+
+
+
+ gavel + + Reported Club Wall Post + +
+ Deferred +
+ +

+ Club Wall moderation and content flagging module. +

+ + +
+ +
+ Moderation Queue + Cleared +
+
+ + {/* Card 3: Upcoming Ride Assignment */} +
+
+
+
+ + assignment_ind + + + Upcoming Ride Assignment + +
+ {rideNeedingSweep ? ( + + Action Needed + + ) : ( + Assigned + )} +
+ +

+ Safety position check for scheduled group rides: +

+ + {rideNeedingSweep ? ( +
+
+ + {rideNeedingSweep.title} + + + {rideNeedingSweep.scheduledStartAt.split('·')[0].trim()} + +
+
+
+ Riders Going: {rideNeedingSweep.confirmedCount} +
+
+ Road Captain: {rideNeedingSweep.roadCaptainName} +
+
+ Sweep: Vacant +
+
+
+ Sweep position is currently unassigned for this ride. +
+
+ ) : ( + + )} +
+ +
+ {rideNeedingSweep && permissions.canPlanRides ? ( + + ) : ( +
+ Positions Stable +
+ )} +
+
+
+
+ ); +} diff --git a/website/components/founder-portal/club-activity-list.tsx b/website/components/founder-portal/club-activity-list.tsx new file mode 100644 index 0000000..4337a20 --- /dev/null +++ b/website/components/founder-portal/club-activity-list.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React, { useState } from 'react'; +import { ClubActivityItem } from './types'; +import { EmptyState } from './empty-state'; + +interface ClubActivityListProps { + activities?: ClubActivityItem[]; +} + +export function ClubActivityList({ activities = [] }: ClubActivityListProps) { + const [filter, setFilter] = useState<'all' | 'membership' | 'ride' | 'wall' | 'admin'>('all'); + + const filtered = activities.filter((item) => { + if (filter === 'all') return true; + return item.type === filter; + }); + + return ( +
+
+
+

+ Club Activity Stream +

+

+ Recent events, memberships, and administrative actions. +

+
+ + {/* Filter Pills */} +
+ {( + [ + { key: 'all', label: 'All Activity' }, + { key: 'membership', label: 'Memberships' }, + { key: 'ride', label: 'Rides' }, + { key: 'wall', label: 'Wall' }, + { key: 'admin', label: 'Administrative' }, + ] as const + ).map((tab) => ( + + ))} +
+
+ + {filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((item) => { + const iconMap = { + membership: 'how_to_reg', + ride: 'add_road', + wall: 'forum', + admin: 'tune', + }; + + return ( +
+
+
+ + {iconMap[item.type] || 'circle'} + +
+
+ {item.actorName}{' '} + {item.actionText} +
+
+
+ {item.timestamp} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/website/components/founder-portal/club-masthead.tsx b/website/components/founder-portal/club-masthead.tsx new file mode 100644 index 0000000..f7ad0fb --- /dev/null +++ b/website/components/founder-portal/club-masthead.tsx @@ -0,0 +1,168 @@ +'use client'; + +import React from 'react'; +import { Club, UserPortalPermissions } from './types'; + +interface ClubMastheadProps { + club: Club; + permissions: UserPortalPermissions; + onPlanRide?: () => void; + onInviteMember?: () => void; + onOpenSettings?: () => void; +} + +export function ClubMasthead({ + club, + permissions, + onPlanRide, + onInviteMember, + onOpenSettings, +}: ClubMastheadProps) { + // Deterministic 2-4 character monogram + const monogram = club.name + .split(/\s+/) + .filter(Boolean) + .map((w) => w[0].toUpperCase()) + .slice(0, 3) + .join('') || 'MS'; + + const visibilityLabel = club.visibility === 'public' ? 'Public' : 'Private'; + const admissionLabel = + club.admissionPolicy === 'inviteOnly' + ? 'Invite Only' + : club.admissionPolicy === 'open' + ? 'Open to Join' + : 'Request to Join'; + + return ( +
+
+
+ {/* Left: Identity & Monogram */} +
+ {/* Precision Hex Monogram Emblem */} +
+ + + + + {monogram} + +
+ + {/* Titles and Club Attributes */} +
+
+

+ {club.name} +

+ + {visibilityLabel} + + + {admissionLabel} + +
+ +
+ @{club.slug} + {club.homeRegion && ( + <> + • + {club.homeRegion} + + )} + {club.foundedYear && ( + <> + • + Founded {club.foundedYear} + + )} + {club.motorcycleFocus && ( + <> + • + {club.motorcycleFocus} + + )} +
+ + {/* Founder Attribution Sub-strip */} +
+
+ Founder: + + {club.founderName ?? 'Marcus Vance'} + + {club.founderHandle && ( + + ({club.founderHandle}) + + )} +
+ • +
+ Club Rank: + + {club.founderRank ?? 'President'} + +
+ • +
+ Administrative Role: + + {permissions.role.charAt(0).toUpperCase() + permissions.role.slice(1)} + +
+
+
+
+ + {/* Right: Founder Actions (Role-gated) */} +
+ {permissions.canPlanRides && ( + + )} + + {permissions.canInviteMembers && ( + + )} + + {permissions.canManageSettings && ( + + )} +
+
+
+ ); +} diff --git a/website/components/founder-portal/club-ride-card.tsx b/website/components/founder-portal/club-ride-card.tsx new file mode 100644 index 0000000..1606a1f --- /dev/null +++ b/website/components/founder-portal/club-ride-card.tsx @@ -0,0 +1,222 @@ +'use client'; + +import React from 'react'; +import { ClubUpcomingRide, UserPortalPermissions } from './types'; +import { EmptyState } from './empty-state'; + +interface UpcomingRidesSectionProps { + rides: ClubUpcomingRide[]; + permissions: UserPortalPermissions; + onPlanRide?: () => void; + onManageRide?: (rideId: string) => void; + onViewRoster?: (rideId: string) => void; + onViewRoute?: (routeId?: string) => void; +} + +export function ClubRideCard({ + ride, + permissions, + onManageRide, + onViewRoster, + onViewRoute, +}: { + ride: ClubUpcomingRide; + permissions: UserPortalPermissions; + onManageRide?: (rideId: string) => void; + onViewRoster?: (rideId: string) => void; + onViewRoute?: (routeId?: string) => void; +}) { + const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75; + + return ( +
+
+ {/* Top Metadata Row */} +
+
+
+ + {ride.scheduledStartAt} + • + Ride ID: {ride.rideIdentifier} +
+

+ {ride.title} +

+
+ + + {ride.paceClassification} + +
+ + {/* Attributes Row */} +
+
+
Distance
+
+ {ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'} +
+
+
+
Duration
+
+ {ride.durationString ?? '--'} +
+
+
+
Pace Classification
+
+ {ride.paceClassification} +
+
+
+ + {/* Roster & Road Captain Details */} +
+
+ Riders: {ride.confirmedCount} going + {ride.pendingCount > 0 ? ( + {ride.pendingCount} requests pending + ) : ride.capacity ? ( + + {Math.max(0, ride.capacity - ride.confirmedCount)} open spots + + ) : null} +
+ + + + {/* Section 15: Verbatim Live Location Privacy Policy Notice */} +
+ + info + + + Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location. + +
+
+ + {/* Actions */} +
+
+ {permissions.canPlanRides ? ( + + ) : ( + + )} + + +
+ + +
+
+ ); +} + +export function UpcomingRidesSection({ + rides, + permissions, + onPlanRide, + onManageRide, + onViewRoster, + onViewRoute, +}: UpcomingRidesSectionProps) { + return ( +
+
+
+

+ Upcoming Club Rides +

+

+ Scheduled Club Rides, routes, participants, and ride assignments. +

+
+ + {rides.length > 0 && ( + + )} +
+ + {rides.length === 0 ? ( + + ) : ( +
+ {rides.map((ride) => ( + + ))} +
+ )} +
+ ); +} diff --git a/website/components/founder-portal/empty-state.tsx b/website/components/founder-portal/empty-state.tsx new file mode 100644 index 0000000..c89ca93 --- /dev/null +++ b/website/components/founder-portal/empty-state.tsx @@ -0,0 +1,44 @@ +'use client'; + +import React from 'react'; + +interface EmptyStateProps { + icon?: string; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + className?: string; +} + +export function EmptyState({ + icon = 'inbox', + title, + description, + actionLabel, + onAction, + className = '', +}: EmptyStateProps) { + return ( +
+
+ {icon} +
+

{title}

+ {description && ( +

{description}

+ )} + {actionLabel && onAction && ( + + )} +
+ ); +} diff --git a/website/components/founder-portal/founder-applications.tsx b/website/components/founder-portal/founder-applications.tsx new file mode 100644 index 0000000..e784c8f --- /dev/null +++ b/website/components/founder-portal/founder-applications.tsx @@ -0,0 +1,690 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { Club, UserPortalPermissions } from './types'; +import { + ClubApplication, + fetchClubApplications, + approveClubApplication, + declineClubApplication, + sanitizePortalError, +} from '../../lib/club-data'; + +interface FounderApplicationsProps { + club: Club; + permissions: UserPortalPermissions; + callerUid: string; + isDemoFixture?: boolean; +} + +export function FounderApplications({ + club, + permissions, + callerUid, + isDemoFixture = false, +}: FounderApplicationsProps) { + const [applications, setApplications] = useState([]); + const [selectedApplicantUid, setSelectedApplicantUid] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'name'>('newest'); + + // Mutation and modal states + const [mutatingUid, setMutatingUid] = useState(null); + const [actionError, setActionError] = useState(null); + const [approveModalApplicant, setApproveModalApplicant] = useState(null); + const [declineModalApplicant, setDeclineModalApplicant] = useState(null); + + // Local member count to reflect approval transitions immediately + const [localMemberCount, setLocalMemberCount] = useState(club.memberCount); + + async function loadApplications() { + setLoading(true); + setError(null); + try { + const data = await fetchClubApplications(club.id, callerUid, { + allowDemoPreview: isDemoFixture, + }); + setApplications(data); + if (data.length > 0) { + setSelectedApplicantUid((prev) => prev ?? data[0].uid); + } + } catch (err) { + setError(sanitizePortalError(err, "Applications couldn't be loaded.")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadApplications(); + }, [club.id, callerUid, isDemoFixture]); + + // Filtered and sorted applications + const filteredApplications = useMemo(() => { + let list = [...applications]; + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase().trim(); + list = list.filter((app) => { + const nameMatch = app.displayName.toLowerCase().includes(q); + const handleMatch = app.handle ? app.handle.toLowerCase().includes(q) : false; + return nameMatch || handleMatch; + }); + } + + list.sort((a, b) => { + if (sortBy === 'newest') { + return new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime(); + } + if (sortBy === 'oldest') { + return new Date(a.requestedAt).getTime() - new Date(b.requestedAt).getTime(); + } + return a.displayName.localeCompare(b.displayName); + }); + + return list; + }, [applications, searchQuery, sortBy]); + + const selectedApplicant = useMemo(() => { + if (!selectedApplicantUid) return filteredApplications[0] || null; + return applications.find((a) => a.uid === selectedApplicantUid) || filteredApplications[0] || null; + }, [applications, filteredApplications, selectedApplicantUid]); + + function getMonogram(name: string) { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return 'CA'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + + function formatDate(isoString: string) { + try { + const date = new Date(isoString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } catch { + return isoString; + } + } + + async function handleApprove(applicant: ClubApplication) { + if (mutatingUid) return; + setMutatingUid(applicant.uid); + setActionError(null); + + try { + if (!isDemoFixture) { + await approveClubApplication(club.id, callerUid, applicant.uid); + } + // Remove from list and increment member count + setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); + setLocalMemberCount((c) => c + 1); + setApproveModalApplicant(null); + + // Select next applicant if the approved one was selected + if (selectedApplicantUid === applicant.uid) { + const remaining = applications.filter((a) => a.uid !== applicant.uid); + setSelectedApplicantUid(remaining[0]?.uid || null); + } + } catch (err) { + setActionError(sanitizePortalError(err, 'Unable to approve application. Please try again.')); + } finally { + setMutatingUid(null); + } + } + + async function handleDecline(applicant: ClubApplication) { + if (mutatingUid) return; + setMutatingUid(applicant.uid); + setActionError(null); + + try { + if (!isDemoFixture) { + await declineClubApplication(club.id, callerUid, applicant.uid); + } + // Remove from list (non-destructive in Firestore, memberCount delta 0) + setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); + setDeclineModalApplicant(null); + + // Select next applicant if the declined one was selected + if (selectedApplicantUid === applicant.uid) { + const remaining = applications.filter((a) => a.uid !== applicant.uid); + setSelectedApplicantUid(remaining[0]?.uid || null); + } + } catch (err) { + setActionError(sanitizePortalError(err, 'Unable to decline application. Please try again.')); + } finally { + setMutatingUid(null); + } + } + + return ( +
+ {/* 1. PAGE HEADER */} +
+
+
+
+ + Precision, Not Aggression + + + // Membership Triage + +
+
+

+ Applications +

+ + {applications.length} PENDING + +
+

Review riders requesting to join this Club.

+
+
+ + {/* 2. SUMMARY METRIC CARDS ROW */} +
+ {/* Card 1: Pending */} +
+
+
+ Pending + +
+
+ {applications.length} +
+
Awaiting review
+
+ + {/* Card 2: Admission Policy */} +
+
+
+ Admission Policy + +
+
+ + {club.admissionPolicy} + +
+
Club join governance rule
+
+ + {/* Card 3: Active Members */} +
+
+
+ Active Members + +
+
+ {localMemberCount} +
+
Current club roster
+
+ + {/* Card 4: Reviewer Authority */} +
+
+
+ Review Authority + +
+
+ + {permissions.role === 'owner' ? 'Owner Access' : 'Admin Access'} + +
+
Software administrative rights
+
+
+ + {/* Action Error Alert */} + {actionError && ( +
+ {actionError} + +
+ )} + + {/* 3. FILTER & SEARCH TOOLBAR */} +
+ {/* Filter Tab */} +
+ +
+ + {/* Controls Right */} +
+ {/* Text Search Bar */} +
+ setSearchQuery(e.target.value)} + className="w-full h-9 pl-9 pr-8 rounded-xl bg-surface-container-low/90 border border-brand-border/80 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan transition-colors" + aria-label="Search applicants" + /> + + + + {searchQuery && ( + + )} +
+ + {/* Sort By Dropdown */} +
+ Sort: + +
+
+
+ + {/* 4. MAIN CONTENT AREA */} + {loading ? ( +
+ + progress_activity + + Loading pending applications... +
+ ) : error ? ( +
+
+ error + Error +
+

{error}

+ +
+ ) : applications.length === 0 ? ( + /* Empty State 1: Default zero-pending queue */ +
+
+ person_add_disabled +
+
+

No Pending Applications

+

+ There are no membership requests waiting for review. New requests will appear here when riders apply. +

+
+
+ ) : filteredApplications.length === 0 ? ( + /* Empty State 2: Filter zero-match */ +
+
+ search_off +
+
+

No Applications Match

+

+ No pending applications match "{searchQuery}". +

+ +
+
+ ) : ( + /* Two-Column Split View */ +
+ {/* Left Column: Dense Application Table */} +
+
+ + + + + + + + + + + {filteredApplications.map((app) => { + const isSelected = selectedApplicant?.uid === app.uid; + const isMutating = mutatingUid === app.uid; + + return ( + setSelectedApplicantUid(app.uid)} + className={`cursor-pointer transition-colors ${ + isSelected + ? 'bg-brand-cyan/10 border-l-2 border-brand-cyan' + : 'hover:bg-surface-container-high/40' + }`} + > + + + + + + ); + })} + +
RiderRequestedStatusActions
+
+
+ {getMonogram(app.displayName)} +
+
+
+ {app.displayName} + {isSelected && ( + + Inspecting + + )} +
+ {app.handle && ( +
+ {app.handle} +
+ )} +
+
+
+ {formatDate(app.requestedAt)} + + + Pending + + +
e.stopPropagation()} + > + + + +
+
+
+ + {/* Pagination / Count Footer */} +
+ + Showing {filteredApplications.length} of {applications.length} pending applications + +
+
+ + {/* Right Column: Selected Application Inspector Drawer */} + {selectedApplicant && ( +
+
+ {/* Top Cyan Accent Line */} +
+ +
+ {/* Header */} +
+
+
+ {getMonogram(selectedApplicant.displayName)} +
+
+

+ {selectedApplicant.displayName} +

+ {selectedApplicant.handle && ( +
+ {selectedApplicant.handle} +
+ )} +
+
+ +
+ + PENDING + + + JOIN REQUEST + +
+
+ +
+ + {/* Rider Spec Grid */} +
+
+ Requested + + {formatDate(selectedApplicant.requestedAt)} + +
+ +
+ Initial Club Rank + Member (Default) +
+ +
+ Administrative Role + Member (Non-admin) +
+
+ + {/* Mandatory Privacy Architecture Notice */} +
+
+ shield + MANDATORY PRIVACY ARCHITECTURE +
+

+ Club administrators do not receive rider telemetry, private routes, or live GPS upon application. Club membership alone never grants access to rider location. Location sharing is strictly scoped to mutually joined active Group Rides. +

+
+ + {/* Architecture Note */} +
+ ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant. +
+ + {/* Primary Actions */} +
+ + + +
+
+
+
+ )} +
+ )} + + {/* MODAL 1: APPROVE APPLICATION */} + {approveModalApplicant && ( +
+
+
+
+ + + Approval Confirmation + +
+
+ +

Approve Application?

+

+ Approve {approveModalApplicant.displayName}'s request to join {club.name}? On approval, this rider becomes an active Club member. +

+ +
+
+ Initial Club Rank: + Member (Default) +
+
+ Administrative Role: + Member (Default Member, non-admin) +
+
+ +
+ ⓘ Club ranks are organizational titles. Administrative roles determine Club management permissions. ClubMember != GroupRideParticipant. +
+ +
+ + +
+
+
+ )} + + {/* MODAL 2: DECLINE APPLICATION */} + {declineModalApplicant && ( +
+
+
+
+ + + Decline Application + +
+
+ +

Decline Application?

+

+ Decline {declineModalApplicant.displayName}'s request to join this Club? This removes the pending request. The rider's MotorSignal account remains unaffected. +

+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/website/components/founder-portal/founder-overview.tsx b/website/components/founder-portal/founder-overview.tsx new file mode 100644 index 0000000..a555d26 --- /dev/null +++ b/website/components/founder-portal/founder-overview.tsx @@ -0,0 +1,240 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Club, + ClubMetrics, + ClubUpcomingRide, + ClubMember, + ClubActivityItem, + UserPortalPermissions, + ClubRole, +} from './types'; +import { ClubMasthead } from './club-masthead'; +import { ExecutiveMetrics } from './metric-card'; +import { AttentionSection } from './attention-card'; +import { UpcomingRidesSection } from './club-ride-card'; +import { RolesAndRanksSummary } from './roles-and-ranks-summary'; +import { ClubActivityList } from './club-activity-list'; + +interface FounderOverviewProps { + club: Club; + metrics: ClubMetrics; + upcomingRides: ClubUpcomingRide[]; + pendingApplications: ClubMember[]; + activities: ClubActivityItem[]; + permissions: UserPortalPermissions; + onRefresh?: () => void; +} + +export function FounderOverview({ + club, + metrics: initialMetrics, + upcomingRides: initialRides, + pendingApplications: initialApps, + activities, + permissions, + onRefresh, +}: FounderOverviewProps) { + const [pendingApps, setPendingApps] = useState(initialApps); + const [rides] = useState(initialRides); + const [metrics, setMetrics] = useState(initialMetrics); + const [notification, setNotification] = useState(null); + + // Modal dialog state + const [activeModal, setActiveModal] = useState<'plan_ride' | 'invite_member' | 'settings' | 'announcement' | 'switch_club' | null>(null); + + function showToast(message: string) { + setNotification(message); + setTimeout(() => { + setNotification((curr) => (curr === message ? null : curr)); + }, 4000); + } + + function handleApproveApplication(uid: string) { + if (!permissions.canApproveApplications) { + showToast('Administrative privileges are required to approve applications.'); + return; + } + const target = pendingApps.find((a) => a.uid === uid); + setPendingApps((prev) => prev.filter((a) => a.uid !== uid)); + setMetrics((prev) => ({ + ...prev, + members: prev.members + 1, + pendingApplications: Math.max(0, prev.pendingApplications - 1), + })); + showToast(`Approved ${target?.displayName ?? 'Applicant'}'s membership.`); + } + + function handleReviewApplication(uid: string) { + if (uid === 'all') { + showToast('Navigating to Applications triage management queue.'); + } else { + const target = pendingApps.find((a) => a.uid === uid); + showToast(`Reviewing applicant profile: ${target?.displayName ?? 'Rider'}`); + } + } + + function handleAssignSweep(rideId: string) { + if (!permissions.canPlanRides) { + showToast('Road Captain or administrative role required to assign sweep positions.'); + return; + } + showToast('Sweep assignment modal opened. Designate qualified Road Marshal.'); + } + + return ( +
+ {/* Toast Notification */} + {notification && ( +
+ info + {notification} + +
+ )} + + {/* Member Access Notice if current role is member */} + {!permissions.canManageSettings && ( +
+
+ visibility + + Viewing Founder Portal in Member View Mode. Administrative actions and mutations are restricted to Club Owner and Admins. + +
+ View-Only +
+ )} + + {/* 1. CLUB IDENTITY MASTHEAD */} + setActiveModal('plan_ride')} + onInviteMember={() => setActiveModal('invite_member')} + onOpenSettings={() => setActiveModal('settings')} + /> + + {/* 2. EXECUTIVE METRICS */} + + + {/* 3. ATTENTION REQUIRED */} + showToast('All pending review alerts acknowledged.')} + /> + + {/* 4. UPCOMING CLUB RIDES */} + setActiveModal('plan_ride')} + onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)} + onViewRoster={(rideId) => showToast(`Displaying confirmed rider roster for Ride ID: ${rideId}`)} + onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)} + /> + + {/* 5. ROLES & RANKS */} + + + + {/* 6. CLUB ACTIVITY */} + + + {/* Modal Dialogs */} + {activeModal && ( +
+
+
+

+ {activeModal === 'plan_ride' && 'Plan Club Ride'} + {activeModal === 'invite_member' && 'Invite New Member'} + {activeModal === 'settings' && 'Club Administration Settings'} + {activeModal === 'announcement' && 'Post Club Announcement'} + {activeModal === 'switch_club' && 'Switch Club Roster'} +

+ +
+ +
+ {activeModal === 'plan_ride' && ( +

+ Create a new scheduled Group Ride associated with {club.name}. Designated routes and checkpoints will sync with ride participants. +

+ )} + {activeModal === 'invite_member' && ( +

+ Send an official membership invitation to a verified rider handle. Once accepted, they will join the club roster. +

+ )} + {activeModal === 'settings' && ( +

+ Club metadata, visibility policy, admission policy, and regional preferences are administered here. +

+ )} + {activeModal === 'announcement' && ( +

+ Broadcast an administrative update to all active members of {club.name}. +

+ )} + {activeModal === 'switch_club' && ( +

+ Select another registered Motorcycle Club from your active memberships. +

+ )} + +
+ FEATURE CONSOLE // STAGE FP-1 PROD FOUNDATION +
+
+ +
+ + +
+
+
+ )} +
+ ); +} 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..0ea4ba4 --- /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..41a782b --- /dev/null +++ b/website/components/founder-portal/founder-profile-editor.tsx @@ -0,0 +1,556 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { Club, UserPortalPermissions } from './types'; +import { updateClubProfile, sanitizePortalError } from '../../lib/club-data'; + +interface FounderProfileEditorProps { + club: Club; + permissions: UserPortalPermissions; + callerUid?: string; + isDemoFixture?: boolean; + onProfileUpdated?: (updated: Partial) => 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} 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 valid
+
+
+ + {/* 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-container-low/90 border border-brand-border/80 focus:border-brand-cyan rounded-xl px-3.5 py-2.5 text-sm font-body text-white outline-none transition-colors" + /> + + {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 + +
+