345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
import Link from 'next/link';
|
|
import { Club, ClubUpcomingRide, UserPortalPermissions } from './types';
|
|
import { EmptyState } from './empty-state';
|
|
import { ClubRideCard } from './club-ride-card';
|
|
|
|
interface FounderRidesProps {
|
|
club: Club;
|
|
permissions: UserPortalPermissions;
|
|
upcomingRides: ClubUpcomingRide[];
|
|
pastRides: ClubUpcomingRide[];
|
|
isLoading?: boolean;
|
|
error?: string | null;
|
|
onPlanRide?: () => void;
|
|
onManageRide?: (rideId: string) => void;
|
|
onViewRoster?: (rideId: string) => void;
|
|
onViewRoute?: (routeId?: string) => void;
|
|
}
|
|
|
|
type TabType = 'upcoming' | 'completed' | 'all';
|
|
|
|
export function FounderRides({
|
|
club,
|
|
permissions,
|
|
upcomingRides,
|
|
pastRides,
|
|
isLoading = false,
|
|
error = null,
|
|
onPlanRide,
|
|
onManageRide,
|
|
onViewRoster,
|
|
onViewRoute,
|
|
}: FounderRidesProps) {
|
|
const [activeTab, setActiveTab] = useState<TabType>('upcoming');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [paceFilter, setPaceFilter] = useState('all');
|
|
|
|
const allRides = useMemo(() => {
|
|
return [...upcomingRides, ...pastRides];
|
|
}, [upcomingRides, pastRides]);
|
|
|
|
// Available pace options from actual data
|
|
const paceOptions = useMemo(() => {
|
|
const paces = new Set<string>();
|
|
for (const r of allRides) {
|
|
if (r.paceClassification) paces.add(r.paceClassification);
|
|
}
|
|
return Array.from(paces);
|
|
}, [allRides]);
|
|
|
|
// Tab-filtered rides
|
|
const tabRides = useMemo(() => {
|
|
if (activeTab === 'upcoming') return upcomingRides;
|
|
if (activeTab === 'completed') return pastRides;
|
|
return allRides;
|
|
}, [activeTab, upcomingRides, pastRides, allRides]);
|
|
|
|
// Search & pace filtered rides
|
|
const filteredRides = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
return tabRides.filter((ride) => {
|
|
const matchesSearch =
|
|
!q ||
|
|
ride.title.toLowerCase().includes(q) ||
|
|
ride.rideIdentifier.toLowerCase().includes(q) ||
|
|
ride.roadCaptainName.toLowerCase().includes(q) ||
|
|
(ride.roadCaptainHandle && ride.roadCaptainHandle.toLowerCase().includes(q)) ||
|
|
(ride.description && ride.description.toLowerCase().includes(q));
|
|
|
|
const matchesPace = paceFilter === 'all' || ride.paceClassification === paceFilter;
|
|
|
|
return matchesSearch && matchesPace;
|
|
});
|
|
}, [tabRides, searchQuery, paceFilter]);
|
|
|
|
// Metrics summary
|
|
const upcomingCount = upcomingRides.length;
|
|
const confirmedRidersTotal = upcomingRides.reduce((sum, r) => sum + r.confirmedCount, 0);
|
|
const completedCount = pastRides.length;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6 animate-pulse" aria-label="Loading club rides">
|
|
<div className="h-8 bg-surface-container-high rounded-xl w-64" />
|
|
<div className="h-20 bg-surface-container-high rounded-2xl" />
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="h-28 bg-surface-container-high rounded-2xl" />
|
|
))}
|
|
</div>
|
|
<div className="h-64 bg-surface-container-high rounded-2xl" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-2xl bg-error-container/20 border border-error/30 p-6 text-center space-y-3">
|
|
<span className="material-symbols-outlined text-error text-[36px]">error</span>
|
|
<h3 className="font-headline text-base font-bold text-white">Unable to Load Club Rides</h3>
|
|
<p className="font-body text-xs text-slate-400 max-w-md mx-auto">{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6" aria-label="Club Rides Management">
|
|
{/* Header Section */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-2.5">
|
|
<h1 className="font-headline text-xl sm:text-2xl font-bold text-white tracking-tight">
|
|
Club Rides
|
|
</h1>
|
|
<span className="font-mono text-xs px-2 py-0.5 rounded-full bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/30 font-semibold">
|
|
{upcomingCount} Scheduled
|
|
</span>
|
|
</div>
|
|
<p className="font-body text-xs text-slate-400 mt-1">
|
|
Schedule, coordinate, and review group rides and road captain assignments.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-400 font-body text-xs cursor-not-allowed select-none"
|
|
title="Founder Portal ride planning is not wired yet"
|
|
aria-disabled="true"
|
|
>
|
|
<span className="material-symbols-outlined text-[17px] text-slate-500">add_road</span>
|
|
<span className="text-slate-400">Plan Club Ride</span>
|
|
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-400 border border-brand-border/60">
|
|
SOON
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Architecture & Privacy Notice Banner */}
|
|
<div className="rounded-2xl bg-surface-container-low border border-brand-border/60 p-4 space-y-2">
|
|
<div className="flex items-start gap-2.5">
|
|
<span className="material-symbols-outlined text-[18px] text-brand-cyan shrink-0 mt-0.5">
|
|
shield
|
|
</span>
|
|
<div className="space-y-1 text-xs">
|
|
<p className="font-body text-slate-300">
|
|
Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
|
|
</p>
|
|
<p className="font-mono text-[11px] text-slate-400">
|
|
Discovery Pending: GroupRide.clubId is live, but broad Founder Portal discovery remains disabled until mutual-block privacy can be preserved.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metrics Summary Strip (4 Cards) */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
{/* Card 1: Upcoming Rides */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="font-mono text-xs tracking-wider">UPCOMING RIDES</span>
|
|
<span className="material-symbols-outlined text-[18px] text-brand-cyan">route</span>
|
|
</div>
|
|
<div className="mt-3">
|
|
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
|
|
{upcomingCount}
|
|
</div>
|
|
<div className="font-body text-[11px] text-slate-400 mt-0.5">
|
|
Active & scheduled
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 2: Confirmed Riders */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="font-mono text-xs tracking-wider">CONFIRMED RIDERS</span>
|
|
<span className="material-symbols-outlined text-[18px] text-brand-cyan">group</span>
|
|
</div>
|
|
<div className="mt-3">
|
|
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
|
|
{confirmedRidersTotal}
|
|
</div>
|
|
<div className="font-body text-[11px] text-slate-400 mt-0.5">
|
|
Across upcoming rides
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 3: Completed Rides */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="font-mono text-xs tracking-wider">COMPLETED RIDES</span>
|
|
<span className="material-symbols-outlined text-[18px] text-brand-cyan">task_alt</span>
|
|
</div>
|
|
<div className="mt-3">
|
|
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
|
|
{completedCount}
|
|
</div>
|
|
<div className="font-body text-[11px] text-slate-400 mt-0.5">
|
|
Recorded club events
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 4: Club Miles */}
|
|
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="font-mono text-xs tracking-wider">CLUB MILES</span>
|
|
<span className="material-symbols-outlined text-[18px] text-slate-500">lock</span>
|
|
</div>
|
|
<div className="mt-3">
|
|
<div className="font-headline text-2xl sm:text-3xl font-bold text-slate-400">
|
|
--
|
|
</div>
|
|
<div className="font-mono text-[10px] text-slate-500 mt-0.5">
|
|
Completed Club Rides only
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 text-[10px] text-slate-500 font-body border-t border-brand-border/40 pt-1.5">
|
|
Calculated from eligible completed Club Rides. Private rider mileage is not automatically included.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs & Search Toolbar */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-1 border-b border-brand-border/60">
|
|
{/* Navigation Tabs */}
|
|
<div className="flex items-center gap-1 bg-surface-container-low p-1 rounded-xl border border-brand-border/60">
|
|
<button
|
|
onClick={() => setActiveTab('upcoming')}
|
|
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
|
|
activeTab === 'upcoming'
|
|
? 'bg-primary-container text-on-primary-container'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
Upcoming ({upcomingCount})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('completed')}
|
|
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
|
|
activeTab === 'completed'
|
|
? 'bg-primary-container text-on-primary-container'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
Completed ({completedCount})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('all')}
|
|
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
|
|
activeTab === 'all'
|
|
? 'bg-primary-container text-on-primary-container'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
All ({allRides.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search & Pace Filter */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Search Input */}
|
|
<div className="relative min-w-[180px] sm:min-w-[220px]">
|
|
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500 text-[16px]">
|
|
search
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search rides or captains..."
|
|
className="w-full h-8 pl-8 pr-3 rounded-lg bg-surface-container-low border border-brand-border/60 text-white placeholder:text-slate-500 font-body text-xs focus:outline-none focus:border-brand-cyan transition-colors"
|
|
aria-label="Search rides or road captains"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs"
|
|
aria-label="Clear search"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pace Filter Dropdown */}
|
|
{paceOptions.length > 0 && (
|
|
<select
|
|
value={paceFilter}
|
|
onChange={(e) => setPaceFilter(e.target.value)}
|
|
className="h-8 px-2.5 rounded-lg bg-surface-container-low border border-brand-border/60 text-slate-300 font-body text-xs focus:outline-none focus:border-brand-cyan cursor-pointer"
|
|
aria-label="Filter by pace classification"
|
|
>
|
|
<option value="all">All Paces</option>
|
|
{paceOptions.map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ride List */}
|
|
{filteredRides.length === 0 ? (
|
|
<EmptyState
|
|
icon="route"
|
|
title={
|
|
searchQuery || paceFilter !== 'all'
|
|
? 'No rides match the selected filters.'
|
|
: activeTab === 'completed'
|
|
? 'No completed club rides recorded.'
|
|
: 'No upcoming club rides scheduled.'
|
|
}
|
|
description={
|
|
searchQuery || paceFilter !== 'all'
|
|
? 'Try adjusting your search query or pace filter.'
|
|
: activeTab === 'completed'
|
|
? 'Completed group rides with telemetry bundles will appear here.'
|
|
: 'Scheduled club rides will appear here once club ride coordination is active.'
|
|
}
|
|
className="py-12"
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
{filteredRides.map((ride) => (
|
|
<ClubRideCard
|
|
key={ride.id}
|
|
ride={ride}
|
|
permissions={permissions}
|
|
clubId={club.slug || club.id}
|
|
onManageRide={onManageRide}
|
|
onViewRoster={onViewRoster}
|
|
onViewRoute={onViewRoute}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|