fix(web): repair clubs management experience
This commit is contained in:
parent
7e47f776e1
commit
b01054b696
|
|
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||
import {
|
||||
fetchFounderClubContext,
|
||||
FounderClubContext,
|
||||
fetchClubApplications,
|
||||
sanitizePortalError,
|
||||
} from '../../../../../lib/club-data';
|
||||
import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell';
|
||||
|
|
@ -23,6 +24,7 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
|||
const searchParams = useSearchParams();
|
||||
|
||||
const [context, setContext] = useState<FounderClubContext | null>(null);
|
||||
const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unauthenticated, setUnauthenticated] = useState(false);
|
||||
|
|
@ -58,6 +60,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
|||
}
|
||||
|
||||
setContext(ctx);
|
||||
|
||||
try {
|
||||
const apps = await fetchClubApplications(ctx.club.id, currentUser?.uid, {
|
||||
allowDemoPreview: isDemoPreview,
|
||||
});
|
||||
setPendingApplicationsCount(apps.length);
|
||||
} catch {
|
||||
setPendingApplicationsCount(0);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(sanitizePortalError(err, "Club applications couldn't be loaded."));
|
||||
} finally {
|
||||
|
|
@ -71,8 +82,10 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface p-6">
|
||||
<OverviewSkeleton />
|
||||
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<OverviewSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,14 +138,15 @@ export default function ClubFounderApplicationsPage({ params }: PageProps) {
|
|||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
activeNav="applications"
|
||||
pendingApplicationsCount={4}
|
||||
upcomingRidesCount={2}
|
||||
pendingApplicationsCount={pendingApplicationsCount}
|
||||
upcomingRidesCount={0}
|
||||
>
|
||||
<FounderApplications
|
||||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
callerUid={context.currentUser.uid}
|
||||
isDemoFixture={context.isDemoFixture}
|
||||
onApplicationsCountChange={setPendingApplicationsCount}
|
||||
/>
|
||||
</FounderPortalShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,8 +71,10 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface p-6">
|
||||
<OverviewSkeleton />
|
||||
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<OverviewSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,8 +127,8 @@ export default function ClubFounderProfilePage({ params }: PageProps) {
|
|||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
activeNav="profile"
|
||||
pendingApplicationsCount={4}
|
||||
upcomingRidesCount={2}
|
||||
pendingApplicationsCount={0}
|
||||
upcomingRidesCount={0}
|
||||
>
|
||||
<FounderProfileEditor
|
||||
club={context.club}
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -71,8 +71,10 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface p-6">
|
||||
<OverviewSkeleton />
|
||||
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<OverviewSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -80,13 +82,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
|||
// Unauthenticated safe error state
|
||||
if (unauthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
||||
<SafeErrorState
|
||||
title="Authentication Required"
|
||||
message="Sign in with your rider account to access club settings."
|
||||
actionLabel="Sign In"
|
||||
onAction={() => router.push('/login')}
|
||||
/>
|
||||
<div 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 settings."
|
||||
actionLabel="Sign In"
|
||||
onAction={() => router.push('/login')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -94,13 +98,15 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
|||
// Unauthorized (Member or non-admin) safe error state
|
||||
if (unauthorized) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
||||
<SafeErrorState
|
||||
title="Management Access Restricted"
|
||||
message="You must be an Owner or Admin of this Club to access Club Settings."
|
||||
actionLabel="Return to Clubs"
|
||||
onAction={() => router.push('/clubs')}
|
||||
/>
|
||||
<div 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="Management Access Restricted"
|
||||
message="You must be an Owner or Admin of this Club to access Club Settings."
|
||||
actionLabel="Return to Clubs"
|
||||
onAction={() => router.push('/clubs')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -108,14 +114,16 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
|||
// Error or missing context
|
||||
if (error || !context) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
||||
<SafeErrorState
|
||||
title="Settings Unavailable"
|
||||
message={error ?? "Club settings couldn't be loaded."}
|
||||
actionLabel="Return to Clubs"
|
||||
onAction={() => router.push('/clubs')}
|
||||
onRetry={loadData}
|
||||
/>
|
||||
<div 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="Settings Unavailable"
|
||||
message={error ?? "Club settings couldn't be loaded."}
|
||||
actionLabel="Return to Clubs"
|
||||
onAction={() => router.push('/clubs')}
|
||||
onRetry={loadData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,8 +133,8 @@ export default function ClubFounderSettingsPage({ params }: PageProps) {
|
|||
club={context.club}
|
||||
permissions={context.permissions}
|
||||
activeNav="settings"
|
||||
pendingApplicationsCount={4}
|
||||
upcomingRidesCount={2}
|
||||
pendingApplicationsCount={0}
|
||||
upcomingRidesCount={0}
|
||||
>
|
||||
<FounderSettingsEditor
|
||||
club={context.club}
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ export default function ClubManageDiscoveryPage() {
|
|||
</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">
|
||||
<span>Manage Cockpit</span>
|
||||
<span>Manage Club</span>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
arrow_forward
|
||||
</span>
|
||||
|
|
@ -276,7 +276,7 @@ export default function ClubManageDiscoveryPage() {
|
|||
<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>
|
||||
</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 →
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function AttentionSection({
|
|||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
|
|
@ -100,9 +100,9 @@ export function AttentionSection({
|
|||
{pendingApplications.slice(0, 3).map((app) => (
|
||||
<div
|
||||
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">
|
||||
{app.displayName ?? 'New Rider'}
|
||||
</div>
|
||||
|
|
@ -111,7 +111,7 @@ export function AttentionSection({
|
|||
</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 && (
|
||||
<button
|
||||
onClick={() => onApproveApplication?.(app.uid)}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export function ClubMasthead({
|
|||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-500">Founder:</span>
|
||||
<span className="text-slate-200 font-medium">
|
||||
{club.founderName ?? 'Marcus Vance'}
|
||||
{club.founderName ?? 'Club Founder'}
|
||||
</span>
|
||||
{club.founderHandle && (
|
||||
<span className="font-mono text-[11px] text-brand-cyan">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ClubUpcomingRide, UserPortalPermissions } from './types';
|
||||
import { EmptyState } from './empty-state';
|
||||
|
||||
interface UpcomingRidesSectionProps {
|
||||
rides: ClubUpcomingRide[];
|
||||
permissions: UserPortalPermissions;
|
||||
clubId?: string;
|
||||
onPlanRide?: () => void;
|
||||
onManageRide?: (rideId: string) => void;
|
||||
onViewRoster?: (rideId: string) => void;
|
||||
|
|
@ -16,16 +19,20 @@ interface UpcomingRidesSectionProps {
|
|||
export function ClubRideCard({
|
||||
ride,
|
||||
permissions,
|
||||
clubId,
|
||||
onManageRide,
|
||||
onViewRoster,
|
||||
onViewRoute,
|
||||
}: {
|
||||
ride: ClubUpcomingRide;
|
||||
permissions: UserPortalPermissions;
|
||||
clubId?: string;
|
||||
onManageRide?: (rideId: string) => void;
|
||||
onViewRoster?: (rideId: 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;
|
||||
|
||||
return (
|
||||
|
|
@ -144,8 +151,10 @@ export function ClubRideCard({
|
|||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onViewRoster?.(ride.id)}
|
||||
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"
|
||||
type="button"
|
||||
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})
|
||||
</button>
|
||||
|
|
@ -166,11 +175,15 @@ export function ClubRideCard({
|
|||
export function UpcomingRidesSection({
|
||||
rides,
|
||||
permissions,
|
||||
clubId,
|
||||
onPlanRide,
|
||||
onManageRide,
|
||||
onViewRoster,
|
||||
onViewRoute,
|
||||
}: UpcomingRidesSectionProps) {
|
||||
const params = useParams();
|
||||
const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : '');
|
||||
|
||||
return (
|
||||
<section className="space-y-4" aria-label="Upcoming Club Rides">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -184,13 +197,13 @@ export function UpcomingRidesSection({
|
|||
</div>
|
||||
|
||||
{rides.length > 0 && (
|
||||
<button
|
||||
onClick={() => onViewRoster?.('all')}
|
||||
className="font-mono text-xs text-brand-cyan hover:underline flex items-center gap-1 cursor-pointer"
|
||||
<span
|
||||
title="Full ride schedule is planned for a future release"
|
||||
className="font-mono text-xs text-slate-500 flex items-center gap-1 cursor-not-allowed"
|
||||
>
|
||||
<span>Full Ride Schedule</span>
|
||||
<span className="material-symbols-outlined text-[13px]">arrow_forward</span>
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[13px] text-slate-600">schedule</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -210,6 +223,7 @@ export function UpcomingRidesSection({
|
|||
key={ride.id}
|
||||
ride={ride}
|
||||
permissions={permissions}
|
||||
clubId={targetClubId}
|
||||
onManageRide={onManageRide}
|
||||
onViewRoster={onViewRoster}
|
||||
onViewRoute={onViewRoute}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface FounderApplicationsProps {
|
|||
permissions: UserPortalPermissions;
|
||||
callerUid: string;
|
||||
isDemoFixture?: boolean;
|
||||
onApplicationsCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function FounderApplications({
|
||||
|
|
@ -22,6 +23,7 @@ export function FounderApplications({
|
|||
permissions,
|
||||
callerUid,
|
||||
isDemoFixture = false,
|
||||
onApplicationsCountChange,
|
||||
}: FounderApplicationsProps) {
|
||||
const [applications, setApplications] = useState<ClubApplication[]>([]);
|
||||
const [selectedApplicantUid, setSelectedApplicantUid] = useState<string | null>(null);
|
||||
|
|
@ -47,6 +49,7 @@ export function FounderApplications({
|
|||
allowDemoPreview: isDemoFixture,
|
||||
});
|
||||
setApplications(data);
|
||||
onApplicationsCountChange?.(data.length);
|
||||
if (data.length > 0) {
|
||||
setSelectedApplicantUid((prev) => prev ?? data[0].uid);
|
||||
}
|
||||
|
|
@ -122,13 +125,14 @@ export function FounderApplications({
|
|||
await approveClubApplication(club.id, callerUid, applicant.uid);
|
||||
}
|
||||
// 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);
|
||||
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) {
|
||||
|
|
@ -148,12 +152,13 @@ export function FounderApplications({
|
|||
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));
|
||||
const remaining = applications.filter((a) => a.uid !== applicant.uid);
|
||||
setApplications(remaining);
|
||||
onApplicationsCountChange?.(remaining.length);
|
||||
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) {
|
||||
|
|
@ -164,7 +169,7 @@ export function FounderApplications({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* 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">
|
||||
<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>
|
||||
) : applications.length === 0 ? (
|
||||
/* 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="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">
|
||||
<span className="material-symbols-outlined text-2xl">person_add_disabled</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white font-mono">No Pending Applications</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
There are no membership requests waiting for review. New requests will appear here when riders apply.
|
||||
</p>
|
||||
<div 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="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-brand-cyan/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-slate-400 shadow-md">
|
||||
<span className="material-symbols-outlined text-2xl text-slate-300">person_add_disabled</span>
|
||||
</div>
|
||||
<h3 className="text-base font-headline font-bold text-white tracking-tight">No Pending Applications</h3>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
|
||||
There are no membership requests waiting for review. New requests will appear here when riders apply to join this Club.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredApplications.length === 0 ? (
|
||||
/* 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="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="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="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>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white font-mono">No Applications Match</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
No pending applications match "{searchQuery}".
|
||||
</p>
|
||||
<h3 className="text-base font-headline font-bold text-white tracking-tight">No Applications Match</h3>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-1.5 max-w-md mx-auto leading-relaxed">
|
||||
No pending applications match "{searchQuery}".
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
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>→</span>
|
||||
<span className="material-symbols-outlined text-sm">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -387,15 +393,15 @@ export function FounderApplications({
|
|||
/* Two-Column Split View */
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
{/* Left Column: Dense Application Table */}
|
||||
<div className="lg:col-span-7 xl:col-span-8 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">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<table className="w-full text-left border-collapse table-auto">
|
||||
<thead>
|
||||
<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">Requested</th>
|
||||
<th className="py-3.5 px-4 font-medium">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-[38%]">Rider</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 w-[16%] whitespace-nowrap">Status</th>
|
||||
<th className="py-3.5 px-4 font-medium w-[26%] text-right whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-brand-border/40 text-xs">
|
||||
|
|
@ -490,7 +496,7 @@ export function FounderApplications({
|
|||
|
||||
{/* Right Column: Selected Application Inspector Drawer */}
|
||||
{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">
|
||||
{/* Top Cyan Accent Line */}
|
||||
<div className="h-0.5 w-full bg-gradient-to-r from-brand-cyan via-blue-500 to-transparent" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Club,
|
||||
ClubMetrics,
|
||||
|
|
@ -36,6 +37,7 @@ export function FounderOverview({
|
|||
permissions,
|
||||
onRefresh,
|
||||
}: FounderOverviewProps) {
|
||||
const router = useRouter();
|
||||
const [pendingApps, setPendingApps] = useState<ClubMember[]>(initialApps);
|
||||
const [rides] = useState<ClubUpcomingRide[]>(initialRides);
|
||||
const [metrics, setMetrics] = useState<ClubMetrics>(initialMetrics);
|
||||
|
|
@ -142,9 +144,16 @@ export function FounderOverview({
|
|||
<UpcomingRidesSection
|
||||
rides={rides}
|
||||
permissions={permissions}
|
||||
clubId={club.slug || club.id}
|
||||
onPlanRide={() => setActiveModal('plan_ride')}
|
||||
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'}`)}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ export function FounderPortalShell({
|
|||
onPostAnnouncement,
|
||||
onSwitchRole,
|
||||
onSwitchClub,
|
||||
pendingApplicationsCount = 4,
|
||||
upcomingRidesCount = 2,
|
||||
pendingApplicationsCount = 0,
|
||||
upcomingRidesCount = 0,
|
||||
children,
|
||||
}: FounderPortalShellProps) {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -25,25 +25,51 @@ export function FounderProfileEditor({
|
|||
const [description, setDescription] = useState(club.description || '');
|
||||
const [homeRegion, setHomeRegion] = useState(club.homeRegion || '');
|
||||
const [motorcycleFocus, setMotorcycleFocus] = useState(club.motorcycleFocus || '');
|
||||
const [logoUrl, setLogoUrl] = useState(club.logoUrl || '');
|
||||
const [bannerUrl, setBannerUrl] = useState(club.bannerUrl || '');
|
||||
|
||||
// Save states
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
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
|
||||
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 isLogoUrlValid = isValidHttpsImageUrl(logoUrl);
|
||||
const isBannerUrlValid = isValidHttpsImageUrl(bannerUrl);
|
||||
const canSave =
|
||||
isNameValid &&
|
||||
isDescValid &&
|
||||
isRegionValid &&
|
||||
isFocusValid &&
|
||||
isLogoUrlValid &&
|
||||
isBannerUrlValid &&
|
||||
!isSaving &&
|
||||
permissions.canManageSettings;
|
||||
|
||||
const isDirty =
|
||||
trimmedName !== (club.name || '').trim() ||
|
||||
description.trim() !== (club.description || '').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
|
||||
const initials = name
|
||||
|
|
@ -61,6 +87,9 @@ export function FounderProfileEditor({
|
|||
setSaveStatus('idle');
|
||||
setErrorMessage(null);
|
||||
|
||||
const trimmedLogo = logoUrl.trim();
|
||||
const trimmedBanner = bannerUrl.trim();
|
||||
|
||||
try {
|
||||
if (isDemoFixture) {
|
||||
// Simulated latency in development demo preview mode
|
||||
|
|
@ -71,6 +100,8 @@ export function FounderProfileEditor({
|
|||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
logoUrl: trimmedLogo || null,
|
||||
bannerUrl: trimmedBanner || null,
|
||||
});
|
||||
} else {
|
||||
if (!callerUid) {
|
||||
|
|
@ -81,6 +112,8 @@ export function FounderProfileEditor({
|
|||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
logoUrl: trimmedLogo,
|
||||
bannerUrl: trimmedBanner,
|
||||
});
|
||||
setSaveStatus('success');
|
||||
onProfileUpdated?.({
|
||||
|
|
@ -88,6 +121,8 @@ export function FounderProfileEditor({
|
|||
description: description.trim(),
|
||||
homeRegion: homeRegion.trim(),
|
||||
motorcycleFocus: motorcycleFocus.trim(),
|
||||
logoUrl: trimmedLogo || null,
|
||||
bannerUrl: trimmedBanner || null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -99,7 +134,7 @@ export function FounderProfileEditor({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
<div className="max-w-6xl mx-auto space-y-6 pb-12">
|
||||
{/* 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">
|
||||
<div>
|
||||
|
|
@ -123,7 +158,7 @@ export function FounderProfileEditor({
|
|||
{/* 4. TOP 4 METRIC CARDS */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 1: Club Name */}
|
||||
<div className="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="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{/* 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="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{/* 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="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{/* 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="flex items-center justify-between">
|
||||
<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 */}
|
||||
<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 gap-2">
|
||||
<span className="material-symbols-outlined text-brand-cyan text-xl">badge</span>
|
||||
|
|
@ -306,7 +341,7 @@ export function FounderProfileEditor({
|
|||
</div>
|
||||
|
||||
{/* 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 gap-2">
|
||||
<span className="material-symbols-outlined text-brand-cyan text-xl">explore</span>
|
||||
|
|
@ -356,8 +391,8 @@ export function FounderProfileEditor({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: LOGO & BANNER (Presentation Only) */}
|
||||
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-7 space-y-5 shadow-xl">
|
||||
{/* SECTION 3: LOGO & BANNER */}
|
||||
<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 gap-2">
|
||||
<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">
|
||||
{/* 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="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>
|
||||
{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 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 ? (
|
||||
<img src={club.logoUrl} alt="Club logo" className="max-h-24 max-w-24 object-contain" />
|
||||
{logoUrl ? (
|
||||
<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">
|
||||
<span className="material-symbols-outlined text-brand-cyan text-2xl">two_wheeler</span>
|
||||
|
|
@ -386,21 +432,50 @@ export function FounderProfileEditor({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400">
|
||||
<span className="material-symbols-outlined text-xs">info</span>
|
||||
<span>Logo and banner editing is not available yet.</span>
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
|
||||
{/* 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="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>
|
||||
{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 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
|
||||
src={club.bannerUrl}
|
||||
src={bannerUrl}
|
||||
alt="Club banner presentation"
|
||||
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="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="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 className="flex items-center gap-1.5 font-mono text-[11px] text-slate-400">
|
||||
<span className="material-symbols-outlined text-xs">info</span>
|
||||
<span>Logo and banner editing is not available yet.</span>
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<span className="material-symbols-outlined text-brand-cyan text-base">check_circle</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">
|
||||
{/* Banner Mockup */}
|
||||
<div className="h-24 bg-surface-container-high relative overflow-hidden">
|
||||
{club.bannerUrl ? (
|
||||
{bannerUrl ? (
|
||||
<img
|
||||
src={club.bannerUrl}
|
||||
src={bannerUrl}
|
||||
alt="Directory banner preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
|
@ -488,9 +581,15 @@ export function FounderProfileEditor({
|
|||
|
||||
<div className="p-4 pt-0 relative">
|
||||
{/* 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">
|
||||
<span className="material-symbols-outlined text-xl">two_wheeler</span>
|
||||
<span className="font-mono text-[10px] font-bold leading-none">{initials}</span>
|
||||
<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="font-mono text-[10px] font-bold leading-none">{initials}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center">
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export function FounderSettingsEditor({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
<div className="max-w-6xl mx-auto space-y-6 pb-12">
|
||||
{/* 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">
|
||||
<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>
|
||||
</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 */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -505,7 +505,7 @@ export function FounderSettingsEditor({
|
|||
</span>
|
||||
</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 */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export function FounderSidebar({
|
|||
permissions,
|
||||
activeNav = 'overview',
|
||||
onNavSelect,
|
||||
pendingApplicationsCount = 4,
|
||||
upcomingRidesCount = 2,
|
||||
pendingApplicationsCount = 0,
|
||||
upcomingRidesCount = 0,
|
||||
isOpenOnMobile = false,
|
||||
onCloseMobile,
|
||||
}: FounderSidebarProps) {
|
||||
|
|
@ -39,17 +39,18 @@ export function FounderSidebar({
|
|||
.join('') || 'ATL';
|
||||
|
||||
const navItemsTop = [
|
||||
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary' },
|
||||
{ id: 'profile', label: 'Club Profile', icon: 'shield' },
|
||||
{ id: 'members', label: 'Members', icon: 'group', badge: club.memberCount.toString(), badgeType: 'neutral' },
|
||||
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false },
|
||||
{ id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false },
|
||||
{ id: 'members', label: 'Members', icon: 'group', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
{
|
||||
id: 'applications',
|
||||
label: 'Applications',
|
||||
icon: 'person_add',
|
||||
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} ALERT` : undefined,
|
||||
badge: pendingApplicationsCount > 0 ? `${pendingApplicationsCount} PENDING` : undefined,
|
||||
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 = [
|
||||
|
|
@ -57,24 +58,26 @@ export function FounderSidebar({
|
|||
id: 'rides',
|
||||
label: 'Rides',
|
||||
icon: 'route',
|
||||
badge: upcomingRidesCount > 0 ? `${upcomingRidesCount} ACTIVE` : undefined,
|
||||
badgeType: 'secondary',
|
||||
badge: 'SOON',
|
||||
badgeType: 'disabled',
|
||||
disabled: true,
|
||||
},
|
||||
{ id: 'routes', label: 'Routes', icon: 'map' },
|
||||
{ id: 'wall', label: 'Wall', icon: 'forum' },
|
||||
{ id: 'chat', label: 'Chat', icon: 'chat' },
|
||||
{ id: 'stats', label: 'Stats', icon: 'query_stats' },
|
||||
{ id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
{ id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
{ id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
{ id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
];
|
||||
|
||||
const navItemsBottom = [
|
||||
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: '1', badgeType: 'neutral' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: 'SOON', badgeType: 'disabled', disabled: true },
|
||||
{ id: 'settings', label: 'Settings', icon: 'settings', disabled: false },
|
||||
];
|
||||
|
||||
function getRouteHref(id: string) {
|
||||
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
|
||||
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
|
||||
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
|
||||
if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`;
|
||||
if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`;
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -84,13 +87,43 @@ export function FounderSidebar({
|
|||
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 href = getRouteHref(item.id);
|
||||
const href = !item.disabled ? getRouteHref(item.id) : undefined;
|
||||
const isProfile = item.id === 'profile';
|
||||
const isSettings = item.id === 'settings';
|
||||
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 = (
|
||||
<>
|
||||
|
|
@ -257,7 +290,7 @@ export function FounderSidebar({
|
|||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-body text-xs font-semibold text-on-surface truncate">
|
||||
{club.founderName ?? 'Marcus Vance'}
|
||||
{club.founderName ?? 'Club Founder'}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[13px] text-brand-cyan shrink-0">
|
||||
verified
|
||||
|
|
|
|||
|
|
@ -50,46 +50,52 @@ export function FounderTopBar({
|
|||
}, [userMenuOpen]);
|
||||
|
||||
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">
|
||||
<div className="w-full max-w-[1440px] mx-auto h-full flex items-center justify-between gap-3 sm:gap-4">
|
||||
<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 px-4 sm:px-6 lg:px-8 flex items-center justify-between gap-3 sm:gap-4">
|
||||
{/* 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 */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[22px]">menu</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 font-mono text-xs text-outline shrink-0">
|
||||
<Link href="/clubs" className="hover:text-on-surface transition-colors">
|
||||
<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 shrink-0">
|
||||
Clubs
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="shrink-0">/</span>
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
{activeNav === 'profile' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span className="text-brand-cyan font-semibold">Club Profile</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'settings' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span className="text-brand-cyan font-semibold">Settings</span>
|
||||
<span className="text-outline-variant shrink-0">/</span>
|
||||
<span className="text-brand-cyan font-semibold truncate">Club Profile</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'applications' && (
|
||||
<>
|
||||
<span className="text-outline-variant">/</span>
|
||||
<span data-breadcrumb="APPLICATIONS" className="text-brand-cyan font-semibold">Applications</span>
|
||||
<span className="text-outline-variant shrink-0">/</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 & Roles</span>
|
||||
</>
|
||||
)}
|
||||
{activeNav === 'settings' && (
|
||||
<>
|
||||
<span className="text-outline-variant shrink-0">/</span>
|
||||
<span className="text-brand-cyan font-semibold truncate">Settings</span>
|
||||
</>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px] text-brand-cyan">swap_horiz</span>
|
||||
<span className="hidden sm:inline">Switch Club</span>
|
||||
<span className="material-symbols-outlined text-[15px] text-outline">expand_more</span>
|
||||
<span className="hidden sm:inline lg:hidden xl:inline">Switch Club</span>
|
||||
<span className="material-symbols-outlined text-[15px] text-outline hidden sm:inline lg:hidden xl:inline">expand_more</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: Search Input (desktop/tablet only) */}
|
||||
<div className="flex-1 max-w-xs mx-2 lg:mx-4 min-w-0 hidden md:block">
|
||||
{/* 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 lg:hidden xl:block">
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[17px]">
|
||||
search
|
||||
|
|
@ -122,11 +128,11 @@ export function FounderTopBar({
|
|||
</div>
|
||||
|
||||
{/* 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) */}
|
||||
{process.env.NODE_ENV === 'development' ? (
|
||||
<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)"
|
||||
>
|
||||
<span className="text-outline-variant">Dev Preview:</span>
|
||||
|
|
@ -146,7 +152,7 @@ export function FounderTopBar({
|
|||
</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"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
|
||||
|
|
@ -169,15 +175,15 @@ export function FounderTopBar({
|
|||
{permissions.canPlanRides && (
|
||||
<button
|
||||
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="hidden sm:inline">Plan Club Ride</span>
|
||||
<span className="hidden sm:inline lg:hidden xl:inline">Plan Club Ride</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative ml-1 shrink-0">
|
||||
<div className="relative ml-0.5 sm:ml-1 shrink-0">
|
||||
<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"
|
||||
title="Notifications"
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ export function MetricCard({
|
|||
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
|
||||
|
||||
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" />
|
||||
{/* 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>
|
||||
{badge}
|
||||
</div>
|
||||
|
|
@ -77,12 +77,12 @@ export function MetricCard({
|
|||
|
||||
{/* Footer Subtext */}
|
||||
<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="truncate">{subtextLeft}</div>
|
||||
<div className="text-slate-400 truncate sm:text-right">{subtextRight}</div>
|
||||
<div className="flex flex-col gap-1 font-mono text-xs text-slate-400">
|
||||
{subtextLeft && <div className="leading-snug">{subtextLeft}</div>}
|
||||
{subtextRight && <div className="text-slate-400 leading-snug">{subtextRight}</div>}
|
||||
</div>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -93,7 +93,7 @@ export function MetricCard({
|
|||
|
||||
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
||||
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 */}
|
||||
<MetricCard
|
||||
label="Members"
|
||||
|
|
@ -101,12 +101,12 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
|||
showSparkline={false}
|
||||
badge={
|
||||
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" />
|
||||
{metrics.membersActivePercent}% ACTIVE
|
||||
</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={
|
||||
|
|
@ -134,11 +134,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
|||
showSparkline={false}
|
||||
badge={
|
||||
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
|
||||
</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={
|
||||
|
|
@ -157,7 +157,7 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
|||
value={metrics.upcomingRidesCount}
|
||||
showSparkline={false}
|
||||
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'}
|
||||
</span>
|
||||
}
|
||||
|
|
@ -178,11 +178,11 @@ export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
|||
isUnavailable={!metrics.clubMilesAvailable}
|
||||
badge={
|
||||
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
|
||||
</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={
|
||||
|
|
|
|||
|
|
@ -609,6 +609,8 @@ export interface UpdateClubProfileInput {
|
|||
description: string;
|
||||
homeRegion: string;
|
||||
motorcycleFocus: string;
|
||||
logoUrl?: string;
|
||||
bannerUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -681,6 +683,8 @@ export async function updateClubProfile(
|
|||
description,
|
||||
homeRegion,
|
||||
motorcycleFocus,
|
||||
...(input.logoUrl !== undefined ? { logoUrl: input.logoUrl.trim() || null } : {}),
|
||||
...(input.bannerUrl !== undefined ? { bannerUrl: input.bannerUrl.trim() || null } : {}),
|
||||
updatedAt: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue