MotorSignal-App/website/components/founder-portal/founder-rides.tsx

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 &amp; 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>
);
}