fix(web): repair clubs management experience

This commit is contained in:
Developer 2026-09-20 02:02:22 -04:00
parent 7e47f776e1
commit b01054b696
17 changed files with 547 additions and 175 deletions

View File

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { import {
fetchFounderClubContext, fetchFounderClubContext,
FounderClubContext, FounderClubContext,
fetchClubApplications,
sanitizePortalError, sanitizePortalError,
} from '../../../../../lib/club-data'; } from '../../../../../lib/club-data';
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell'; import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
@ -23,6 +24,7 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [context, setContext] = useState<FounderClubContext | null>(null); const [context, setContext] = useState<FounderClubContext | null>(null);
const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [unauthenticated, setUnauthenticated] = useState(false); const [unauthenticated, setUnauthenticated] = useState(false);
@ -58,6 +60,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
} }
setContext(ctx); setContext(ctx);
try {
const apps = await fetchClubApplications(ctx.club.id, currentUser?.uid, {
allowDemoPreview: isDemoPreview,
});
setPendingApplicationsCount(apps.length);
} catch {
setPendingApplicationsCount(0);
}
} catch (err) { } catch (err) {
setError(sanitizePortalError(err, "Club applications couldn't be loaded.")); setError(sanitizePortalError(err, "Club applications couldn't be loaded."));
} finally { } finally {
@ -71,9 +82,11 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-surface p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton /> <OverviewSkeleton />
</div> </div>
</div>
); );
} }
@ -125,14 +138,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
club={context.club} club={context.club}
permissions={context.permissions} permissions={context.permissions}
activeNav="applications" activeNav="applications"
pendingApplicationsCount={4} pendingApplicationsCount={pendingApplicationsCount}
upcomingRidesCount={2} upcomingRidesCount={0}
> >
<FounderApplications <FounderApplications
club={context.club} club={context.club}
permissions={context.permissions} permissions={context.permissions}
callerUid={context.currentUser.uid} callerUid={context.currentUser.uid}
isDemoFixture={context.isDemoFixture} isDemoFixture={context.isDemoFixture}
onApplicationsCountChange={setPendingApplicationsCount}
/> />
</FounderPortalShell> </FounderPortalShell>
); );

View File

@ -71,9 +71,11 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-surface p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton /> <OverviewSkeleton />
</div> </div>
</div>
); );
} }
@ -125,8 +127,8 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
club={context.club} club={context.club}
permissions={context.permissions} permissions={context.permissions}
activeNav="profile" activeNav="profile"
pendingApplicationsCount={4} pendingApplicationsCount={0}
upcomingRidesCount={2} upcomingRidesCount={0}
> >
<FounderProfileEditor <FounderProfileEditor
club={context.club} club={context.club}

View File

@ -0,0 +1,177 @@
'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 { RolesAndRanksSummary } from '../../../../../components/founder-portal/roles-and-ranks-summary';
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 ClubFounderRanksRolesPage({ 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);
async function loadData() {
setLoading(true);
setError(null);
setUnauthenticated(false);
try {
await firebaseAuth.authStateReady();
const currentUser = firebaseAuth.currentUser;
const isDemoPreview = searchParams?.get('preview') === 'demo';
if (!isDemoPreview && !currentUser) {
setUnauthenticated(true);
setLoading(false);
return;
}
const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, {
allowDemoPreview: isDemoPreview,
});
setContext(ctx);
} catch (err) {
setError(sanitizePortalError(err, "Ranks and roles couldn't be loaded."));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData();
}, [clubId]);
if (loading) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton />
</div>
</div>
);
}
if (unauthenticated) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState
title="Authentication Required"
message="Sign in with your rider account to access club ranks and roles."
actionLabel="Sign In"
onAction={() => router.push('/login')}
/>
</div>
</div>
);
}
if (error || !context) {
return (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState
title="Ranks & Roles Unavailable"
message={error ?? "Ranks and roles couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
</div>
);
}
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="ranks_roles"
pendingApplicationsCount={0}
upcomingRidesCount={0}
>
<div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* Header */}
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
<div>
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-brand-cyan tracking-widest bg-brand-cyan/10 px-2.5 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
Governance &amp; Authority
</span>
<span className="font-mono text-xs text-slate-400 tracking-wider">
// {context.club.name}
</span>
</div>
<h1 className="font-headline font-bold text-2xl sm:text-3xl text-white mt-2 tracking-tight">
Ranks &amp; Roles
</h1>
<p className="font-body text-sm text-slate-400 mt-1">
Administrative role authority vs honorary squad rank designations.
</p>
</div>
</section>
{/* Roles and Ranks Summary Component */}
<RolesAndRanksSummary
ownerName={context.ownerProfile?.displayName || context.club.founderName || 'Club Founder'}
memberCount={context.club.memberCount}
/>
{/* Rank Hierarchy Detail Table */}
<section className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-4 shadow-lg">
<div className="border-b border-brand-border/60 pb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-lg">military_tech</span>
<h2 className="font-headline text-base font-bold text-white">
Squad Rank Structure
</h2>
</div>
<span className="font-mono text-[11px] text-slate-400">
6 Recognized Titles
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{[
{ rank: 'President', desc: 'Club founder and chief organizational executive.', level: 'Rank 1' },
{ rank: 'Vice President', desc: 'Senior operational deputy and squadron coordinator.', level: 'Rank 2' },
{ rank: 'Road Captain', desc: 'Leads pace line, group positioning, and on-road safety.', level: 'Rank 3' },
{ rank: 'Road Marshal', desc: 'Rear sweep and group communications integrity.', level: 'Rank 4' },
{ rank: 'Senior Rider', desc: 'Veteran rider with 1,000+ verified club group miles.', level: 'Rank 5' },
{ rank: 'Member', desc: 'Verified active rider in regular squadron standing.', level: 'Rank 6' },
].map((r) => (
<div key={r.rank} className="p-4 rounded-xl bg-surface-container-low border border-brand-border/60 space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-headline font-semibold text-white text-xs">{r.rank}</span>
<span className="font-mono text-[10px] text-brand-cyan px-2 py-0.5 rounded bg-brand-cyan/10 border border-brand-cyan/30">
{r.level}
</span>
</div>
<p className="font-body text-xs text-slate-400 leading-relaxed">{r.desc}</p>
</div>
))}
</div>
</section>
</div>
</FounderPortalShell>
);
}

View File

@ -71,16 +71,19 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-surface p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton /> <OverviewSkeleton />
</div> </div>
</div>
); );
} }
// Unauthenticated safe error state // Unauthenticated safe error state
if (unauthenticated) { if (unauthenticated) {
return ( return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState <SafeErrorState
title="Authentication Required" title="Authentication Required"
message="Sign in with your rider account to access club settings." message="Sign in with your rider account to access club settings."
@ -88,13 +91,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
onAction={() => router.push('/login')} onAction={() => router.push('/login')}
/> />
</div> </div>
</div>
); );
} }
// Unauthorized (Member or non-admin) safe error state // Unauthorized (Member or non-admin) safe error state
if (unauthorized) { if (unauthorized) {
return ( return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState <SafeErrorState
title="Management Access Restricted" title="Management Access Restricted"
message="You must be an Owner or Admin of this Club to access Club Settings." message="You must be an Owner or Admin of this Club to access Club Settings."
@ -102,13 +107,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
onAction={() => router.push('/clubs')} onAction={() => router.push('/clubs')}
/> />
</div> </div>
</div>
); );
} }
// Error or missing context // Error or missing context
if (error || !context) { if (error || !context) {
return ( return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6"> <div className="min-h-screen bg-brand-bg telemetry-grid-bg flex items-center justify-center p-6">
<div className="max-w-5xl mx-auto w-full flex items-center justify-center">
<SafeErrorState <SafeErrorState
title="Settings Unavailable" title="Settings Unavailable"
message={error ?? "Club settings couldn't be loaded."} message={error ?? "Club settings couldn't be loaded."}
@ -117,6 +124,7 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
onRetry={loadData} onRetry={loadData}
/> />
</div> </div>
</div>
); );
} }
@ -125,8 +133,8 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
club={context.club} club={context.club}
permissions={context.permissions} permissions={context.permissions}
activeNav="settings" activeNav="settings"
pendingApplicationsCount={4} pendingApplicationsCount={0}
upcomingRidesCount={2} upcomingRidesCount={0}
> >
<FounderSettingsEditor <FounderSettingsEditor
club={context.club} club={context.club}

View File

@ -261,7 +261,7 @@ export default function ClubManageDiscoveryPage() {
</div> </div>
<div className="flex items-center gap-2 self-end sm:self-center font-mono text-xs text-brand-cyan group-hover:translate-x-1 transition-transform shrink-0 font-medium"> <div className="flex items-center gap-2 self-end sm:self-center font-mono text-xs text-brand-cyan group-hover:translate-x-1 transition-transform shrink-0 font-medium">
<span>Manage Cockpit</span> <span>Manage Club</span>
<span className="material-symbols-outlined text-[20px]"> <span className="material-symbols-outlined text-[20px]">
arrow_forward arrow_forward
</span> </span>
@ -276,7 +276,7 @@ export default function ClubManageDiscoveryPage() {
<span className="material-symbols-outlined text-[18px] text-brand-cyan">shield</span> <span className="material-symbols-outlined text-[18px] text-brand-cyan">shield</span>
<span>Only clubs where you are an active Owner or Admin appear here.</span> <span>Only clubs where you are an active Owner or Admin appear here.</span>
</div> </div>
<Link href="/clubs" className="font-mono text-xs text-brand-cyan hover:underline"> <Link href="/clubs#directory" className="font-mono text-xs text-brand-cyan hover:underline">
All Public Clubs → All Public Clubs →
</Link> </Link>
</div> </div>

View File

@ -62,7 +62,7 @@ export function AttentionSection({
</div> </div>
{/* 3 Triage Cards Grid */} {/* 3 Triage Cards Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{/* Card 1: Membership Applications */} {/* Card 1: Membership Applications */}
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md"> <div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
<div> <div>
@ -100,9 +100,9 @@ export function AttentionSection({
{pendingApplications.slice(0, 3).map((app) => ( {pendingApplications.slice(0, 3).map((app) => (
<div <div
key={app.uid} key={app.uid}
className="p-2.5 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-between gap-2" className="p-2.5 rounded-lg bg-surface-container-high border border-brand-border/60 flex items-center justify-between gap-2 flex-wrap"
> >
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="font-body text-xs font-semibold text-white truncate"> <div className="font-body text-xs font-semibold text-white truncate">
{app.displayName ?? 'New Rider'} {app.displayName ?? 'New Rider'}
</div> </div>
@ -111,7 +111,7 @@ export function AttentionSection({
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0 ml-auto">
{permissions.canApproveApplications && ( {permissions.canApproveApplications && (
<button <button
onClick={() => onApproveApplication?.(app.uid)} onClick={() => onApproveApplication?.(app.uid)}

View File

@ -103,7 +103,7 @@ export function ClubMasthead({
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-slate-500">Founder:</span> <span className="text-slate-500">Founder:</span>
<span className="text-slate-200 font-medium"> <span className="text-slate-200 font-medium">
{club.founderName ?? 'Marcus Vance'} {club.founderName ?? 'Club Founder'}
</span> </span>
{club.founderHandle && ( {club.founderHandle && (
<span className="font-mono text-[11px] text-brand-cyan"> <span className="font-mono text-[11px] text-brand-cyan">

View File

@ -1,12 +1,15 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ClubUpcomingRide, UserPortalPermissions } from './types'; import { ClubUpcomingRide, UserPortalPermissions } from './types';
import { EmptyState } from './empty-state'; import { EmptyState } from './empty-state';
interface UpcomingRidesSectionProps { interface UpcomingRidesSectionProps {
rides: ClubUpcomingRide[]; rides: ClubUpcomingRide[];
permissions: UserPortalPermissions; permissions: UserPortalPermissions;
clubId?: string;
onPlanRide?: () => void; onPlanRide?: () => void;
onManageRide?: (rideId: string) => void; onManageRide?: (rideId: string) => void;
onViewRoster?: (rideId: string) => void; onViewRoster?: (rideId: string) => void;
@ -16,16 +19,20 @@ interface UpcomingRidesSectionProps {
export function ClubRideCard({ export function ClubRideCard({
ride, ride,
permissions, permissions,
clubId,
onManageRide, onManageRide,
onViewRoster, onViewRoster,
onViewRoute, onViewRoute,
}: { }: {
ride: ClubUpcomingRide; ride: ClubUpcomingRide;
permissions: UserPortalPermissions; permissions: UserPortalPermissions;
clubId?: string;
onManageRide?: (rideId: string) => void; onManageRide?: (rideId: string) => void;
onViewRoster?: (rideId: string) => void; onViewRoster?: (rideId: string) => void;
onViewRoute?: (routeId?: string) => void; onViewRoute?: (routeId?: string) => void;
}) { }) {
const params = useParams();
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75; const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75;
return ( return (
@ -144,8 +151,10 @@ export function ClubRideCard({
)} )}
<button <button
onClick={() => onViewRoster?.(ride.id)} type="button"
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors cursor-pointer" disabled
title="Member roster view is planned for a future release"
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs font-medium cursor-not-allowed flex items-center justify-center"
> >
View Roster ({ride.confirmedCount}) View Roster ({ride.confirmedCount})
</button> </button>
@ -166,11 +175,15 @@ export function ClubRideCard({
export function UpcomingRidesSection({ export function UpcomingRidesSection({
rides, rides,
permissions, permissions,
clubId,
onPlanRide, onPlanRide,
onManageRide, onManageRide,
onViewRoster, onViewRoster,
onViewRoute, onViewRoute,
}: UpcomingRidesSectionProps) { }: UpcomingRidesSectionProps) {
const params = useParams();
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
return ( return (
<section className="space-y-4" aria-label="Upcoming Club Rides"> <section className="space-y-4" aria-label="Upcoming Club Rides">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -184,13 +197,13 @@ export function UpcomingRidesSection({
</div> </div>
{rides.length > 0 && ( {rides.length > 0 && (
<button <span
onClick={() => onViewRoster?.('all')} title="Full ride schedule is planned for a future release"
className="font-mono text-xs text-brand-cyan hover:underline flex items-center gap-1 cursor-pointer" className="font-mono text-xs text-slate-500 flex items-center gap-1 cursor-not-allowed"
> >
<span>Full Ride Schedule</span> <span>Full Ride Schedule</span>
<span className="material-symbols-outlined text-[13px]">arrow_forward</span> <span className="material-symbols-outlined text-[13px] text-slate-600">schedule</span>
</button> </span>
)} )}
</div> </div>
@ -210,6 +223,7 @@ export function UpcomingRidesSection({
key={ride.id} key={ride.id}
ride={ride} ride={ride}
permissions={permissions} permissions={permissions}
clubId={targetClubId}
onManageRide={onManageRide} onManageRide={onManageRide}
onViewRoster={onViewRoster} onViewRoster={onViewRoster}
onViewRoute={onViewRoute} onViewRoute={onViewRoute}

View File

@ -15,6 +15,7 @@ interface FounderApplicationsProps {
permissions: UserPortalPermissions; permissions: UserPortalPermissions;
callerUid: string; callerUid: string;
isDemoFixture?: boolean; isDemoFixture?: boolean;
onApplicationsCountChange?: (count: number) => void;
} }
export function FounderApplications({ export function FounderApplications({
@ -22,6 +23,7 @@ export function FounderApplications({
permissions, permissions,
callerUid, callerUid,
isDemoFixture = false, isDemoFixture = false,
onApplicationsCountChange,
}: FounderApplicationsProps) { }: FounderApplicationsProps) {
const [applications, setApplications] = useState<ClubApplication[]>([]); const [applications, setApplications] = useState<ClubApplication[]>([]);
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null); const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
@ -47,6 +49,7 @@ export function FounderApplications({
allowDemoPreview: isDemoFixture, allowDemoPreview: isDemoFixture,
}); });
setApplications(data); setApplications(data);
onApplicationsCountChange?.(data.length);
if (data.length > 0) { if (data.length > 0) {
setSelectedApplicantUid((prev) => prev ?? data[0].uid); setSelectedApplicantUid((prev) => prev ?? data[0].uid);
} }
@ -122,13 +125,14 @@ export function FounderApplications({
await approveClubApplication(club.id, callerUid, applicant.uid); await approveClubApplication(club.id, callerUid, applicant.uid);
} }
// Remove from list and increment member count // Remove from list and increment member count
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); const remaining = applications.filter((a) => a.uid !== applicant.uid);
setApplications(remaining);
onApplicationsCountChange?.(remaining.length);
setLocalMemberCount((c) => c + 1); setLocalMemberCount((c) => c + 1);
setApproveModalApplicant(null); setApproveModalApplicant(null);
// Select next applicant if the approved one was selected // Select next applicant if the approved one was selected
if (selectedApplicantUid === applicant.uid) { if (selectedApplicantUid === applicant.uid) {
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setSelectedApplicantUid(remaining[0]?.uid || null); setSelectedApplicantUid(remaining[0]?.uid || null);
} }
} catch (err) { } catch (err) {
@ -148,12 +152,13 @@ export function FounderApplications({
await declineClubApplication(club.id, callerUid, applicant.uid); await declineClubApplication(club.id, callerUid, applicant.uid);
} }
// Remove from list (non-destructive in Firestore, memberCount delta 0) // Remove from list (non-destructive in Firestore, memberCount delta 0)
setApplications((prev) => prev.filter((a) => a.uid !== applicant.uid)); const remaining = applications.filter((a) => a.uid !== applicant.uid);
setApplications(remaining);
onApplicationsCountChange?.(remaining.length);
setDeclineModalApplicant(null); setDeclineModalApplicant(null);
// Select next applicant if the declined one was selected // Select next applicant if the declined one was selected
if (selectedApplicantUid === applicant.uid) { if (selectedApplicantUid === applicant.uid) {
const remaining = applications.filter((a) => a.uid !== applicant.uid);
setSelectedApplicantUid(remaining[0]?.uid || null); setSelectedApplicantUid(remaining[0]?.uid || null);
} }
} catch (err) { } catch (err) {
@ -164,7 +169,7 @@ export function FounderApplications({
} }
return ( return (
<div className="space-y-6"> <div className="max-w-6xl mx-auto space-y-6">
{/* 1. PAGE HEADER */} {/* 1. PAGE HEADER */}
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg"> <section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
@ -352,34 +357,35 @@ export function FounderApplications({
</div> </div>
) : applications.length === 0 ? ( ) : applications.length === 0 ? (
/* Empty State 1: Default zero-pending queue */ /* Empty State 1: Default zero-pending queue */
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg"> <div className="rounded-2xl glass-card border border-brand-border/60 p-8 sm:p-12 text-center shadow-xl relative overflow-hidden max-w-2xl mx-auto">
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 flex items-center justify-center mx-auto text-slate-400"> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-brand-cyan/10 to-transparent" />
<span className="material-symbols-outlined text-2xl">person_add_disabled</span> <div className="w-14 h-14 rounded-2xl bg-surface-container-high border border-brand-border/80 flex items-center justify-center mx-auto mb-4 text-slate-400 shadow-md">
<span className="material-symbols-outlined text-2xl text-slate-300">person_add_disabled</span>
</div> </div>
<div> <h3 className="text-base font-headline font-bold text-white tracking-tight">No Pending Applications</h3>
<h3 className="text-sm font-semibold text-white font-mono">No Pending Applications</h3> <p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto"> There are no membership requests waiting for review. New requests will appear here when riders apply to join this Club.
There are no membership requests waiting for review. New requests will appear here when riders apply.
</p> </p>
</div> </div>
</div>
) : filteredApplications.length === 0 ? ( ) : filteredApplications.length === 0 ? (
/* Empty State 2: Filter zero-match */ /* Empty State 2: Filter zero-match */
<div className="p-10 rounded-2xl glass-card border border-brand-border/60 text-center space-y-3 shadow-lg"> <div className="rounded-2xl glass-card border border-brand-border/60 p-8 sm:p-12 text-center shadow-xl relative overflow-hidden max-w-2xl mx-auto">
<div className="w-12 h-12 rounded-xl bg-surface-container-high border border-brand-border/60 flex items-center justify-center mx-auto text-amber-400"> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-amber-400/50 via-amber-400/10 to-transparent" />
<div className="w-14 h-14 rounded-2xl bg-surface-container-high border border-brand-border/80 flex items-center justify-center mx-auto mb-4 text-amber-400 shadow-md">
<span className="material-symbols-outlined text-2xl">search_off</span> <span className="material-symbols-outlined text-2xl">search_off</span>
</div> </div>
<div> <h3 className="text-base font-headline font-bold text-white tracking-tight">No Applications Match</h3>
<h3 className="text-sm font-semibold text-white font-mono">No Applications Match</h3> <p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
<p className="text-xs text-slate-400 mt-1">
No pending applications match &quot;{searchQuery}&quot;. No pending applications match &quot;{searchQuery}&quot;.
</p> </p>
<div className="mt-4">
<button <button
type="button"
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery('')}
className="text-xs text-brand-cyan hover:underline font-medium inline-flex items-center space-x-1 mt-2" className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-brand-border/80 text-xs font-mono text-brand-cyan transition-colors"
> >
<span>Clear Search</span> <span>Clear Search</span>
<span>→</span> <span className="material-symbols-outlined text-sm">close</span>
</button> </button>
</div> </div>
</div> </div>
@ -387,15 +393,15 @@ export function FounderApplications({
/* Two-Column Split View */ /* Two-Column Split View */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Dense Application Table */} {/* Left Column: Dense Application Table */}
<div className="lg:col-span-7 xl:col-span-8 rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg flex flex-col"> <div className="lg:col-span-7 xl:col-span-7 rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg flex flex-col">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse table-auto">
<thead> <thead>
<tr className="border-b border-brand-border/60 bg-surface-container-high/60 text-[11px] font-mono text-slate-400 tracking-wider"> <tr className="border-b border-brand-border/60 bg-surface-container-high/60 text-[11px] font-mono text-slate-400 tracking-wider">
<th className="py-3.5 px-4 font-medium">Rider</th> <th className="py-3.5 px-4 font-medium w-[38%]">Rider</th>
<th className="py-3.5 px-4 font-medium">Requested</th> <th className="py-3.5 px-4 font-medium w-[20%] whitespace-nowrap">Requested</th>
<th className="py-3.5 px-4 font-medium">Status</th> <th className="py-3.5 px-4 font-medium w-[16%] whitespace-nowrap">Status</th>
<th className="py-3.5 px-4 font-medium text-right">Actions</th> <th className="py-3.5 px-4 font-medium w-[26%] text-right whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-brand-border/40 text-xs"> <tbody className="divide-y divide-brand-border/40 text-xs">
@ -490,7 +496,7 @@ export function FounderApplications({
{/* Right Column: Selected Application Inspector Drawer */} {/* Right Column: Selected Application Inspector Drawer */}
{selectedApplicant && ( {selectedApplicant && (
<div className="lg:col-span-5 xl:col-span-4 space-y-4"> <div className="lg:col-span-5 xl:col-span-5 space-y-4">
<div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative"> <div className="rounded-2xl glass-card border border-brand-border/60 overflow-hidden shadow-lg relative">
{/* Top Cyan Accent Line */} {/* Top Cyan Accent Line */}
<div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" /> <div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" />

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { import {
Club, Club,
ClubMetrics, ClubMetrics,
@ -36,6 +37,7 @@ export function FounderOverview({
permissions, permissions,
onRefresh, onRefresh,
}: FounderOverviewProps) { }: FounderOverviewProps) {
const router = useRouter();
const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps); const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps);
const [rides] = useState<ClubUpcomingRide[]>(initialRides); const [rides] = useState<ClubUpcomingRide[]>(initialRides);
const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics); const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics);
@ -142,9 +144,16 @@ export function FounderOverview({
<UpcomingRidesSection <UpcomingRidesSection
rides={rides} rides={rides}
permissions={permissions} permissions={permissions}
clubId={club.slug || club.id}
onPlanRide={() => setActiveModal('plan_ride')} onPlanRide={() => setActiveModal('plan_ride')}
onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)} onManageRide={(rideId) => showToast(`Opening management cockpit for Ride ID: ${rideId}`)}
onViewRoster={(rideId) => showToast(`Displaying confirmed rider roster for Ride ID: ${rideId}`)} onViewRoster={(rideId) => {
if (rideId === 'all') {
showToast('Full ride schedule is planned for a future release.');
} else {
showToast('Member roster details will be available in an upcoming release.');
}
}}
onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)} onViewRoute={(routeId) => showToast(`Opening route navigation map: ${routeId ?? 'default'}`)}
/> />

View File

@ -28,8 +28,8 @@ export function FounderPortalShell({
onPostAnnouncement, onPostAnnouncement,
onSwitchRole, onSwitchRole,
onSwitchClub, onSwitchClub,
pendingApplicationsCount = 4, pendingApplicationsCount = 0,
upcomingRidesCount = 2, upcomingRidesCount = 0,
children, children,
}: FounderPortalShellProps) { }: FounderPortalShellProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);

View File

@ -25,25 +25,51 @@ export function FounderProfileEditor({
const [description, setDescription] = useState(club.description || ''); const [description, setDescription] = useState(club.description || '');
const [homeRegion, setHomeRegion] = useState(club.homeRegion || ''); const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || ''); const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
const [logoUrl, setLogoUrl] = useState(club.logoUrl || '');
const [bannerUrl, setBannerUrl] = useState(club.bannerUrl || '');
// Save states // Save states
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Image URL validation (HTTPS only or empty to clear)
function isValidHttpsImageUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return true;
try {
const parsed = new URL(trimmed);
return parsed.protocol === 'https:';
} catch {
return false;
}
}
// Validation // Validation
const trimmedName = name.trim(); const trimmedName = name.trim();
const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50; const isNameValid = trimmedName.length >= 3 && trimmedName.length <= 50;
const isDescValid = description.trim().length <= 500; const isDescValid = description.trim().length <= 500;
const isRegionValid = homeRegion.trim().length <= 100; const isRegionValid = homeRegion.trim().length <= 100;
const isFocusValid = motorcycleFocus.trim().length <= 100; const isFocusValid = motorcycleFocus.trim().length <= 100;
const canSave = isNameValid && isDescValid && isRegionValid && isFocusValid && !isSaving && permissions.canManageSettings; const isLogoUrlValid = isValidHttpsImageUrl(logoUrl);
const isBannerUrlValid = isValidHttpsImageUrl(bannerUrl);
const canSave =
isNameValid &&
isDescValid &&
isRegionValid &&
isFocusValid &&
isLogoUrlValid &&
isBannerUrlValid &&
!isSaving &&
permissions.canManageSettings;
const isDirty = const isDirty =
trimmedName !== (club.name || '').trim() || trimmedName !== (club.name || '').trim() ||
description.trim() !== (club.description || '').trim() || description.trim() !== (club.description || '').trim() ||
homeRegion.trim() !== (club.homeRegion || '').trim() || homeRegion.trim() !== (club.homeRegion || '').trim() ||
motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim(); motorcycleFocus.trim() !== (club.motorcycleFocus || '').trim() ||
logoUrl.trim() !== (club.logoUrl || '').trim() ||
bannerUrl.trim() !== (club.bannerUrl || '').trim();
// Short initials for preview emblem // Short initials for preview emblem
const initials = name const initials = name
@ -61,6 +87,9 @@ export function FounderProfileEditor({
setSaveStatus('idle'); setSaveStatus('idle');
setErrorMessage(null); setErrorMessage(null);
const trimmedLogo = logoUrl.trim();
const trimmedBanner = bannerUrl.trim();
try { try {
if (isDemoFixture) { if (isDemoFixture) {
// Simulated latency in development demo preview mode // Simulated latency in development demo preview mode
@ -71,6 +100,8 @@ export function FounderProfileEditor({
description: description.trim(), description: description.trim(),
homeRegion: homeRegion.trim(), homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(), motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo || null,
bannerUrl: trimmedBanner || null,
}); });
} else { } else {
if (!callerUid) { if (!callerUid) {
@ -81,6 +112,8 @@ export function FounderProfileEditor({
description: description.trim(), description: description.trim(),
homeRegion: homeRegion.trim(), homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(), motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo,
bannerUrl: trimmedBanner,
}); });
setSaveStatus('success'); setSaveStatus('success');
onProfileUpdated?.({ onProfileUpdated?.({
@ -88,6 +121,8 @@ export function FounderProfileEditor({
description: description.trim(), description: description.trim(),
homeRegion: homeRegion.trim(), homeRegion: homeRegion.trim(),
motorcycleFocus: motorcycleFocus.trim(), motorcycleFocus: motorcycleFocus.trim(),
logoUrl: trimmedLogo || null,
bannerUrl: trimmedBanner || null,
}); });
} }
} catch (err) { } catch (err) {
@ -99,7 +134,7 @@ export function FounderProfileEditor({
} }
return ( return (
<div className="space-y-6 pb-12"> <div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* 3. PAGE HEADER */} {/* 3. PAGE HEADER */}
<section className="border-b border-brand-border/60 pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4"> <section className="border-b border-brand-border/60 pb-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
@ -123,7 +158,7 @@ export function FounderProfileEditor({
{/* 4. TOP 4 METRIC CARDS */} {/* 4. TOP 4 METRIC CARDS */}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Club Name */} {/* Card 1: Club Name */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden"> <div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Club Name</span> <span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Club Name</span>
@ -136,7 +171,7 @@ export function FounderProfileEditor({
</div> </div>
{/* Card 2: Home Region */} {/* Card 2: Home Region */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden"> <div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Home Region</span> <span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Home Region</span>
@ -151,7 +186,7 @@ export function FounderProfileEditor({
</div> </div>
{/* Card 3: Motorcycle Focus */} {/* Card 3: Motorcycle Focus */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden"> <div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Motorcycle Focus</span> <span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Motorcycle Focus</span>
@ -166,7 +201,7 @@ export function FounderProfileEditor({
</div> </div>
{/* Card 4: Discovery Presence */} {/* Card 4: Discovery Presence */}
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 shadow-lg flex flex-col justify-between overflow-hidden"> <div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 shadow-lg flex flex-col justify-between overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Discovery Presence</span> <span className="font-mono text-xs text-slate-400 uppercase tracking-wider font-medium">Discovery Presence</span>
@ -217,7 +252,7 @@ export function FounderProfileEditor({
)} )}
{/* SECTION 1: PUBLIC CLUB IDENTITY */} {/* SECTION 1: PUBLIC CLUB IDENTITY */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl"> <div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5"> <div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">badge</span> <span className="material-symbols-outlined text-brand-cyan text-xl">badge</span>
@ -306,7 +341,7 @@ export function FounderProfileEditor({
</div> </div>
{/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */} {/* SECTION 2: REGIONAL & MOTORCYCLE FOCUS */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl"> <div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5"> <div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">explore</span> <span className="material-symbols-outlined text-brand-cyan text-xl">explore</span>
@ -356,8 +391,8 @@ export function FounderProfileEditor({
</div> </div>
</div> </div>
{/* SECTION 3: LOGO & BANNER (Presentation Only) */} {/* SECTION 3: LOGO & BANNER */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl"> <div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-5 shadow-xl">
<div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5"> <div className="flex items-center justify-between border-b border-brand-border/60 pb-3.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-xl">palette</span> <span className="material-symbols-outlined text-brand-cyan text-xl">palette</span>
@ -371,12 +406,23 @@ export function FounderProfileEditor({
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Logo Presentation Card */} {/* Logo Presentation Card */}
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3"> <div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-xs text-slate-400"> <div className="flex items-center justify-between font-mono text-xs text-slate-400">
<span>Club Emblem</span> <span>Club Emblem</span>
{logoUrl && (
<button
type="button"
onClick={() => setLogoUrl('')}
disabled={!permissions.canManageSettings || isSaving}
className="text-rose-400 hover:text-rose-300 transition-colors text-[11px] font-mono flex items-center gap-1 cursor-pointer"
>
<span className="material-symbols-outlined text-xs">close</span>
<span>Remove</span>
</button>
)}
</div> </div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl flex flex-col items-center justify-center relative overflow-hidden"> <div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl flex flex-col items-center justify-center relative overflow-hidden">
{club.logoUrl ? ( {logoUrl ? (
<img src={club.logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain" /> <img src={logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain rounded-lg" />
) : ( ) : (
<div className="w-20 h-20 rounded-xl border-2 border-brand-cyan bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative"> <div className="w-20 h-20 rounded-xl border-2 border-brand-cyan bg-surface-container-high flex flex-col items-center justify-center shadow-lg relative">
<span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span> <span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span>
@ -386,21 +432,50 @@ export function FounderProfileEditor({
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400"> <div className="space-y-2 pt-1">
<span className="material-symbols-outlined text-xs">info</span> <div className="space-y-1">
<span>Logo and banner editing is not available yet.</span> <input
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="https://example.com/logo.png"
className={`w-full bg-surface-container-low/90 border ${
!isLogoUrlValid ? 'border-error focus:border-error' : 'border-brand-border/80 focus:border-brand-cyan'
} rounded-xl px-3 py-2 text-xs font-mono text-white outline-none transition-colors`}
/>
{!isLogoUrlValid && (
<p className="font-mono text-[10px] text-error">
Must be a valid HTTPS URL (e.g. https://example.com/logo.png).
</p>
)}
</div>
<p className="font-mono text-[10px] text-slate-400">
Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank.
</p>
</div> </div>
</div> </div>
{/* Banner Presentation Card */} {/* Banner Presentation Card */}
<div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3"> <div className="bg-surface-container-low/70 border border-brand-border/60 rounded-xl p-4 flex flex-col justify-between space-y-3">
<div className="font-mono text-xs text-slate-400"> <div className="flex items-center justify-between font-mono text-xs text-slate-400">
<span>Banner Presentation</span> <span>Banner Presentation</span>
{bannerUrl && (
<button
type="button"
onClick={() => setBannerUrl('')}
disabled={!permissions.canManageSettings || isSaving}
className="text-rose-400 hover:text-rose-300 transition-colors text-[11px] font-mono flex items-center gap-1 cursor-pointer"
>
<span className="material-symbols-outlined text-xs">close</span>
<span>Remove</span>
</button>
)}
</div> </div>
<div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl relative overflow-hidden flex items-center justify-center"> <div className="h-36 bg-surface-container-lowest border border-dashed border-brand-border/80 rounded-xl relative overflow-hidden flex items-center justify-center">
{club.bannerUrl ? ( {bannerUrl ? (
<img <img
src={club.bannerUrl} src={bannerUrl}
alt="Club banner presentation" alt="Club banner presentation"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -409,21 +484,39 @@ export function FounderProfileEditor({
<div className="absolute inset-0 bg-[radial-gradient(#00f2fe_1px,transparent_1px)] [background-size:16px_16px] opacity-10" /> <div className="absolute inset-0 bg-[radial-gradient(#00f2fe_1px,transparent_1px)] [background-size:16px_16px] opacity-10" />
<div className="flex items-center gap-2 font-mono text-xs text-slate-400 z-10"> <div className="flex items-center gap-2 font-mono text-xs text-slate-400 z-10">
<span className="material-symbols-outlined text-base text-brand-cyan">image</span> <span className="material-symbols-outlined text-base text-brand-cyan">image</span>
<span className="tracking-wider font-semibold text-white">{club.name}</span> <span className="tracking-wider font-semibold text-white">{name || club.name}</span>
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400"> <div className="space-y-2 pt-1">
<span className="material-symbols-outlined text-xs">info</span> <div className="space-y-1">
<span>Logo and banner editing is not available yet.</span> <input
type="url"
value={bannerUrl}
onChange={(e) => setBannerUrl(e.target.value)}
disabled={!permissions.canManageSettings || isSaving}
placeholder="https://example.com/banner.jpg"
className={`w-full bg-surface-container-low/90 border ${
!isBannerUrlValid ? 'border-error focus:border-error' : 'border-brand-border/80 focus:border-brand-cyan'
} rounded-xl px-3 py-2 text-xs font-mono text-white outline-none transition-colors`}
/>
{!isBannerUrlValid && (
<p className="font-mono text-[10px] text-error">
Must be a valid HTTPS URL (e.g. https://example.com/banner.jpg).
</p>
)}
</div>
<p className="font-mono text-[10px] text-slate-400">
Direct file upload requires Cloud Storage integration (planned for future release). Enter an HTTPS image URL or leave blank.
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* ACTION DOCKED FOOTER */} {/* ACTION DOCKED FOOTER */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-5 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl"> <div className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl">
<div className="flex items-center gap-2 font-body text-xs text-slate-300"> <div className="flex items-center gap-2 font-body text-xs text-slate-300">
<span className="material-symbols-outlined text-brand-cyan text-base">check_circle</span> <span className="material-symbols-outlined text-brand-cyan text-base">check_circle</span>
<span>Saves public club profile information.</span> <span>Saves public club profile information.</span>
@ -472,9 +565,9 @@ export function FounderProfileEditor({
<div className="rounded-xl bg-surface-container-low border border-brand-border/60 overflow-hidden shadow-lg"> <div className="rounded-xl bg-surface-container-low border border-brand-border/60 overflow-hidden shadow-lg">
{/* Banner Mockup */} {/* Banner Mockup */}
<div className="h-24 bg-surface-container-high relative overflow-hidden"> <div className="h-24 bg-surface-container-high relative overflow-hidden">
{club.bannerUrl ? ( {bannerUrl ? (
<img <img
src={club.bannerUrl} src={bannerUrl}
alt="Directory banner preview" alt="Directory banner preview"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -488,9 +581,15 @@ export function FounderProfileEditor({
<div className="p-4 pt-0 relative"> <div className="p-4 pt-0 relative">
{/* Emblem overlapping */} {/* Emblem overlapping */}
<div className="-mt-8 w-14 h-14 rounded-xl bg-surface-container-high border-2 border-brand-cyan flex flex-col items-center justify-center text-brand-cyan shadow-md"> <div className="-mt-8 w-14 h-14 rounded-xl bg-surface-container-high border-2 border-brand-cyan flex flex-col items-center justify-center text-brand-cyan shadow-md overflow-hidden">
{logoUrl ? (
<img src={logoUrl} alt="Emblem preview" className="w-full h-full object-cover" />
) : (
<>
<span className="material-symbols-outlined text-xl">two_wheeler</span> <span className="material-symbols-outlined text-xl">two_wheeler</span>
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span> <span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
</>
)}
</div> </div>
<div className="mt-2 flex items-center"> <div className="mt-2 flex items-center">

View File

@ -186,7 +186,7 @@ export function FounderSettingsEditor({
} }
return ( return (
<div className="space-y-6 pb-12"> <div className="max-w-6xl mx-auto space-y-6 pb-12">
{/* 2. HEADER */} {/* 2. HEADER */}
<section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg"> <section className="p-6 rounded-2xl glass-card border border-brand-border/60 relative overflow-hidden flex flex-col md:flex-row md:items-end justify-between gap-4 shadow-lg">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/60 via-brand-cyan/20 to-transparent" />
@ -397,7 +397,7 @@ export function FounderSettingsEditor({
</span> </span>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
{/* PUBLIC */} {/* PUBLIC */}
<button <button
type="button" type="button"
@ -505,7 +505,7 @@ export function FounderSettingsEditor({
</span> </span>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
{/* OPEN */} {/* OPEN */}
<button <button
type="button" type="button"

View File

@ -21,8 +21,8 @@ export function FounderSidebar({
permissions, permissions,
activeNav = 'overview', activeNav = 'overview',
onNavSelect, onNavSelect,
pendingApplicationsCount = 4, pendingApplicationsCount = 0,
upcomingRidesCount = 2, upcomingRidesCount = 0,
isOpenOnMobile = false, isOpenOnMobile = false,
onCloseMobile, onCloseMobile,
}: FounderSidebarProps) { }: FounderSidebarProps) {
@ -39,17 +39,18 @@ export function FounderSidebar({
.join('') || 'ATL'; .join('') || 'ATL';
const navItemsTop = [ const navItemsTop = [
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary' }, { id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false },
{ id: 'profile', label: 'Club Profile', icon: 'shield' }, { id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false },
{ id: 'members', label: 'Members', icon: 'group', badge: club.memberCount.toString(), badgeType: 'neutral' }, { id: 'members', label: 'Members', icon: 'group', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ {
id: 'applications', id: 'applications',
label: 'Applications', label: 'Applications',
icon: 'person_add', icon: 'person_add',
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} ALERT` : undefined, badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} PENDING` : undefined,
badgeType: 'error', badgeType: 'error',
disabled: false,
}, },
{ id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech' }, { id: 'ranks_roles', label: 'Ranks & Roles', icon: 'military_tech', disabled: false },
]; ];
const navItemsMiddle = [ const navItemsMiddle = [
@ -57,24 +58,26 @@ export function FounderSidebar({
id: 'rides', id: 'rides',
label: 'Rides', label: 'Rides',
icon: 'route', icon: 'route',
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount} ACTIVE` : undefined, badge: 'SOON',
badgeType: 'secondary', badgeType: 'disabled',
disabled: true,
}, },
{ id: 'routes', label: 'Routes', icon: 'map' }, { id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'wall', label: 'Wall', icon: 'forum' }, { id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'chat', label: 'Chat', icon: 'chat' }, { id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'stats', label: 'Stats', icon: 'query_stats' }, { id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true },
]; ];
const navItemsBottom = [ const navItemsBottom = [
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: '1', badgeType: 'neutral' }, { id: 'moderation', label: 'Moderation', icon: 'gavel', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'settings', label: 'Settings', icon: 'settings' }, { id: 'settings', label: 'Settings', icon: 'settings', disabled: false },
]; ];
function getRouteHref(id: string) { function getRouteHref(id: string) {
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`; if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`; if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`; if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`;
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`; if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
return undefined; return undefined;
} }
@ -84,13 +87,43 @@ export function FounderSidebar({
onCloseMobile?.(); onCloseMobile?.();
} }
function renderNavItem(item: { id: string; label: string; icon: string; badge?: string; badgeType?: string }) { function renderNavItem(item: {
id: string;
label: string;
icon: string;
badge?: string;
badgeType?: string;
disabled?: boolean;
}) {
const isActive = activeNav === item.id; const isActive = activeNav === item.id;
const href = getRouteHref(item.id); const href = !item.disabled ? getRouteHref(item.id) : undefined;
const isProfile = item.id === 'profile'; const isProfile = item.id === 'profile';
const isSettings = item.id === 'settings'; const isSettings = item.id === 'settings';
const isApplications = item.id === 'applications'; const isApplications = item.id === 'applications';
const isSubRoute = isProfile || isSettings || isApplications; const isSubRoute = isProfile || isSettings || isApplications || item.id === 'ranks_roles';
if (item.disabled) {
return (
<div
key={item.id}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs text-slate-500/60 cursor-not-allowed select-none"
title={`${item.label} is planned for a future release`}
aria-disabled="true"
>
<div className="flex items-center gap-2.5">
<span className="material-symbols-outlined text-[18px] text-slate-600">
{item.icon}
</span>
<span className="text-slate-500">{item.label}</span>
</div>
{item.badge && (
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-500 border border-brand-border/40">
{item.badge}
</span>
)}
</div>
);
}
const content = ( const content = (
<> <>
@ -257,7 +290,7 @@ export function FounderSidebar({
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-body text-xs font-semibold text-on-surface truncate"> <span className="font-body text-xs font-semibold text-on-surface truncate">
{club.founderName ?? 'Marcus Vance'} {club.founderName ?? 'Club Founder'}
</span> </span>
<span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0"> <span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0">
verified verified

View File

@ -50,46 +50,52 @@ export function FounderTopBar({
}, [userMenuOpen]); }, [userMenuOpen]);
return ( return (
<header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-brand-border/60 z-40 px-4 sm:px-6 lg:px-8"> <header className="fixed top-0 left-0 lg:left-72 right-0 h-16 bg-surface-container-lowest/95 backdrop-blur-md border-b border-brand-border/60 z-40">
<div className="w-full max-w-[1440px] mx-auto h-full flex items-center justify-between gap-3 sm:gap-4"> <div className="w-full max-w-[1440px] mx-auto h-full px-4 sm:px-6 lg:px-8 flex items-center justify-between gap-3 sm:gap-4">
{/* Left: Mobile hamburger + Breadcrumbs + Switch Club */} {/* Left: Mobile hamburger + Breadcrumbs + Switch Club */}
<div className="flex items-center gap-3 sm:gap-4 min-w-0"> <div className="flex items-center gap-2.5 sm:gap-4 min-w-0">
{/* Mobile Hamburger Toggle */} {/* Mobile Hamburger Toggle */}
<button <button
onClick={onToggleMobileSidebar} onClick={onToggleMobileSidebar}
className="lg:hidden text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors" className="lg:hidden text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors shrink-0"
aria-label="Open sidebar" aria-label="Open sidebar"
> >
<span className="material-symbols-outlined text-[22px]">menu</span> <span className="material-symbols-outlined text-[22px]">menu</span>
</button> </button>
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0"> <div className="flex items-center gap-1.5 sm:gap-2 font-mono text-xs text-outline min-w-0">
<Link href="/clubs" className="hover:text-on-surface transition-colors"> <Link href="/clubs" className="hover:text-on-surface transition-colors shrink-0">
Clubs Clubs
</Link> </Link>
<span>/</span> <span className="shrink-0">/</span>
<Link <Link
href={`/clubs/${club.slug || club.id}/manage`} href={`/clubs/${club.slug || club.id}/manage`}
className="text-on-surface font-semibold truncate max-w-[120px] sm:max-w-[180px] md:max-w-none hover:text-brand-cyan transition-colors" className="text-on-surface font-semibold truncate max-w-[110px] sm:max-w-[160px] lg:max-w-[140px] xl:max-w-none hover:text-brand-cyan transition-colors"
> >
{club.name} {club.name}
</Link> </Link>
{activeNav === 'profile' && ( {activeNav === 'profile' && (
<> <>
<span className="text-outline-variant">/</span> <span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold">Club Profile</span> <span className="text-brand-cyan font-semibold truncate">Club Profile</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant">/</span>
<span className="text-brand-cyan font-semibold">Settings</span>
</> </>
)} )}
{activeNav === 'applications' && ( {activeNav === 'applications' && (
<> <>
<span className="text-outline-variant">/</span> <span className="text-outline-variant shrink-0">/</span>
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold">Applications</span> <span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold truncate">Applications</span>
</>
)}
{activeNav === 'ranks_roles' && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">Ranks &amp; Roles</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">Settings</span>
</> </>
)} )}
</div> </div>
@ -100,14 +106,14 @@ export function FounderTopBar({
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs hover:bg-surface-container-high transition-colors" className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs hover:bg-surface-container-high transition-colors"
> >
<span className="material-symbols-outlined text-[15px] text-brand-cyan">swap_horiz</span> <span className="material-symbols-outlined text-[15px] text-brand-cyan">swap_horiz</span>
<span className="hidden sm:inline">Switch Club</span> <span className="hidden sm:inline lg:hidden xl:inline">Switch Club</span>
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span> <span className="material-symbols-outlined text-[15px] text-outline hidden sm:inline lg:hidden xl:inline">expand_more</span>
</button> </button>
</div> </div>
</div> </div>
{/* Middle: Search Input (desktop/tablet only) */} {/* Middle: Search Input (desktop/tablet only, hidden on lg to prevent crush) */}
<div className="flex-1 max-w-xs mx-2 lg:mx-4 min-w-0 hidden md:block"> <div className="flex-1 max-w-xs mx-2 lg:mx-4 min-w-0 hidden md:block lg:hidden xl:block">
<div className="relative"> <div className="relative">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]"> <span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
search search
@ -122,11 +128,11 @@ export function FounderTopBar({
</div> </div>
{/* Right: Actions + Role Switcher preview pill + Notifications + Profile */} {/* Right: Actions + Role Switcher preview pill + Notifications + Profile */}
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0"> <div className="flex items-center gap-2 sm:gap-2.5 shrink-0 flex-wrap justify-end">
{/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */} {/* Authoritative Role Badge (Production) vs Interactive Role Switcher (Development Only) */}
{process.env.NODE_ENV === 'development' ? ( {process.env.NODE_ENV === 'development' ? (
<div <div
className="hidden xl:flex items-center gap-1 px-2 py-0.5 rounded-lg bg-surface-container-high border border-brand-border/60 text-[11px] font-mono text-outline" className="hidden 2xl:flex items-center gap-1 px-2 py-0.5 rounded-lg bg-surface-container-high border border-brand-border/60 text-[11px] font-mono text-outline"
title="Interactive role preview for testing software permissions (Development Only)" title="Interactive role preview for testing software permissions (Development Only)"
> >
<span className="text-outline-variant">Dev Preview:</span> <span className="text-outline-variant">Dev Preview:</span>
@ -146,7 +152,7 @@ export function FounderTopBar({
</div> </div>
) : ( ) : (
<div <div
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-container border border-brand-border/60 text-[11px] font-mono text-outline capitalize font-medium" className="hidden sm:flex lg:hidden xl:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-container border border-brand-border/60 text-[11px] font-mono text-outline capitalize font-medium"
title="Authoritative software role" title="Authoritative software role"
> >
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
@ -169,15 +175,15 @@ export function FounderTopBar({
{permissions.canPlanRides && ( {permissions.canPlanRides && (
<button <button
onClick={onPlanRide} onClick={onPlanRide}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all shrink-0" className="flex items-center gap-1.5 px-3 sm:px-3.5 py-1.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-[0_0_12px_rgba(0,242,254,0.25)] transition-all shrink-0"
> >
<span className="material-symbols-outlined text-[15px]">add_road</span> <span className="material-symbols-outlined text-[15px]">add_road</span>
<span className="hidden sm:inline">Plan Club Ride</span> <span className="hidden sm:inline lg:hidden xl:inline">Plan Club Ride</span>
</button> </button>
)} )}
{/* Notifications */} {/* Notifications */}
<div className="relative ml-1 shrink-0"> <div className="relative ml-0.5 sm:ml-1 shrink-0">
<button <button
className="w-8 h-8 rounded-xl bg-surface-container border border-brand-border/60 flex items-center justify-center text-outline hover:text-on-surface transition-colors" className="w-8 h-8 rounded-xl bg-surface-container border border-brand-border/60 flex items-center justify-center text-outline hover:text-on-surface transition-colors"
title="Notifications" title="Notifications"

View File

@ -37,10 +37,10 @@ export function MetricCard({
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary; const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
return ( return (
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden"> <div className="relative rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
{/* Top Header Row */} {/* Top Header Row */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-start justify-between gap-2 flex-wrap">
<span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span> <span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span>
{badge} {badge}
</div> </div>
@ -77,12 +77,12 @@ export function MetricCard({
{/* Footer Subtext */} {/* Footer Subtext */}
<div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40"> <div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 font-mono text-xs text-slate-400"> <div className="flex flex-col gap-1 font-mono text-xs text-slate-400">
<div className="truncate">{subtextLeft}</div> {subtextLeft && <div className="leading-snug">{subtextLeft}</div>}
<div className="text-slate-400 truncate sm:text-right">{subtextRight}</div> {subtextRight && <div className="text-slate-400 leading-snug">{subtextRight}</div>}
</div> </div>
{disclaimer && ( {disclaimer && (
<span className="text-[10px] font-mono text-slate-500 truncate" title={disclaimer}> <span className="text-[10px] font-mono text-slate-500 line-clamp-2 leading-relaxed" title={disclaimer}>
{disclaimer} {disclaimer}
</span> </span>
)} )}
@ -93,7 +93,7 @@ export function MetricCard({
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) { export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
return ( return (
<section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-5"> <section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 2xl:grid-cols-4 gap-4 lg:gap-5">
{/* 1. MEMBERS */} {/* 1. MEMBERS */}
<MetricCard <MetricCard
label="Members" label="Members"
@ -101,12 +101,12 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
showSparkline={false} showSparkline={false}
badge={ badge={
metrics.membersActivePercent != null ? ( metrics.membersActivePercent != null ? (
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30"> <span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
{metrics.membersActivePercent}% ACTIVE {metrics.membersActivePercent}% ACTIVE
</span> </span>
) : ( ) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ROSTER</span> <span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">ROSTER</span>
) )
} }
subtextLeft={ subtextLeft={
@ -134,11 +134,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
showSparkline={false} showSparkline={false}
badge={ badge={
metrics.pendingApplications > 0 ? ( metrics.pendingApplications > 0 ? (
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30"> <span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30 shrink-0">
REQUIRES REVIEW REQUIRES REVIEW
</span> </span>
) : ( ) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ALL CLEARED</span> <span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">ALL CLEARED</span>
) )
} }
subtextLeft={ subtextLeft={
@ -157,7 +157,7 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
value={metrics.upcomingRidesCount} value={metrics.upcomingRidesCount}
showSparkline={false} showSparkline={false}
badge={ badge={
<span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium"> <span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium shrink-0">
{metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'} {metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'}
</span> </span>
} }
@ -178,11 +178,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
isUnavailable={!metrics.clubMilesAvailable} isUnavailable={!metrics.clubMilesAvailable}
badge={ badge={
metrics.clubMilesAvailable ? ( metrics.clubMilesAvailable ? (
<span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium"> <span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium max-w-full text-right leading-tight">
Completed Club Rides only Completed Club Rides only
</span> </span>
) : ( ) : (
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">UNAVAILABLE</span> <span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60 shrink-0">UNAVAILABLE</span>
) )
} }
subtextLeft={ subtextLeft={

View File

@ -609,6 +609,8 @@ export interface UpdateClubProfileInput {
description: string; description: string;
homeRegion: string; homeRegion: string;
motorcycleFocus: string; motorcycleFocus: string;
logoUrl?: string;
bannerUrl?: string;
} }
/** /**
@ -681,6 +683,8 @@ export async function updateClubProfile(
description, description,
homeRegion, homeRegion,
motorcycleFocus, motorcycleFocus,
...(input.logoUrl !== undefined ? { logoUrl: input.logoUrl.trim() || null } : {}),
...(input.bannerUrl !== undefined ? { bannerUrl: input.bannerUrl.trim() || null } : {}),
updatedAt: serverTimestamp(), updatedAt: serverTimestamp(),
}); });
} }