MotorSignal-App/website/components/group-rides/group-rides-directory.tsx

335 lines
16 KiB
TypeScript

'use client';
import React, { useState, useMemo } from 'react';
import { GroupRideSummary, RidePaceFilter, RideSortOption } from './types';
interface GroupRidesDirectoryProps {
rides: GroupRideSummary[];
onRequestSlot: (rideId: string) => void;
}
export function GroupRidesDirectory({ rides, onRequestSlot }: GroupRidesDirectoryProps) {
const [filterPace, setFilterPace] = useState<RidePaceFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<RideSortOption>('time');
// Count badges for filters
const counts = useMemo(() => {
return {
all: rides.length,
brisk: rides.filter((r) => r.paceClassification === 'Brisk Pace').length,
track: rides.filter((r) => r.paceClassification === 'Track Pace').length,
tour: rides.filter(
(r) =>
r.paceClassification === 'Weekend Tours' ||
r.paceClassification === 'Endurance / Chill'
).length,
};
}, [rides]);
// Filtered & sorted rides
const filteredRides = useMemo(() => {
let result = rides.filter((ride) => {
// Pace classification filter
if (filterPace === 'brisk' && ride.paceClassification !== 'Brisk Pace') return false;
if (filterPace === 'track' && ride.paceClassification !== 'Track Pace') return false;
if (
filterPace === 'tour' &&
ride.paceClassification !== 'Weekend Tours' &&
ride.paceClassification !== 'Endurance / Chill'
)
return false;
// Search query filter
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
const matchesTitle = ride.title.toLowerCase().includes(q);
const matchesRegion = ride.region.toLowerCase().includes(q);
const matchesLead = ride.leadHandle.toLowerCase().includes(q);
const matchesDesc = ride.description.toLowerCase().includes(q);
if (!matchesTitle && !matchesRegion && !matchesLead && !matchesDesc) return false;
}
return true;
});
// Sorting
result.sort((a, b) => {
if (sortOption === 'distance') {
const distA = parseFloat(a.distance) || 0;
const distB = parseFloat(b.distance) || 0;
return distB - distA;
}
if (sortOption === 'riders') {
return b.participantCount - a.participantCount;
}
// default: time / alphabetical id
return a.title.localeCompare(b.title);
});
return result;
}, [rides, filterPace, searchQuery, sortOption]);
return (
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl" data-testid="group-rides-directory">
<div className="flex flex-col md:flex-row md:items-end justify-between mb-space-xl gap-space-md">
<div>
<span className="font-label-caps text-label-caps text-primary-fixed-dim tracking-wide block mb-1">
Regional Operations
</span>
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
Active &amp; Upcoming Pack Runs
</h2>
</div>
{/* Filter Toggles */}
<div className="flex flex-wrap items-center gap-space-xs bg-surface-container-low p-1 rounded-xl border border-outline-variant/20">
<button
type="button"
onClick={() => setFilterPace('all')}
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
filterPace === 'all'
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
: 'text-on-surface-variant hover:text-on-surface'
}`}
>
All Runs ({counts.all})
</button>
<button
type="button"
onClick={() => setFilterPace('brisk')}
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
filterPace === 'brisk'
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
: 'text-on-surface-variant hover:text-on-surface'
}`}
>
Brisk Pace ({counts.brisk})
</button>
<button
type="button"
onClick={() => setFilterPace('track')}
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
filterPace === 'track'
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
: 'text-on-surface-variant hover:text-on-surface'
}`}
>
Track / Apex ({counts.track})
</button>
<button
type="button"
onClick={() => setFilterPace('tour')}
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
filterPace === 'tour'
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
: 'text-on-surface-variant hover:text-on-surface'
}`}
>
Weekend Tours ({counts.tour})
</button>
</div>
</div>
{/* Search and Region Filters Bar */}
<div className="bg-surface-container-lowest p-space-md rounded-xl mb-space-lg flex flex-wrap items-center justify-between gap-space-md shadow-md border border-outline-variant/30">
<div className="flex items-center gap-space-sm flex-1 min-w-[280px]">
<span className="material-symbols-outlined text-on-surface-variant text-[20px]">search</span>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by region, canyon, or road marshal..."
className="w-full bg-transparent font-body-md text-body-md text-on-surface placeholder:text-on-surface-variant outline-none"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="text-on-surface-variant hover:text-on-surface p-1 rounded"
title="Clear search"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
)}
</div>
<div className="flex items-center gap-space-sm font-code-mono text-code-mono text-on-surface-variant">
<span>Sort by:</span>
<select
value={sortOption}
onChange={(e) => setSortOption(e.target.value as RideSortOption)}
className="bg-surface-container px-2 py-1 rounded text-primary-container font-bold outline-none cursor-pointer border border-outline-variant/20"
>
<option value="time">Start Time ↓</option>
<option value="distance">Distance ↓</option>
<option value="riders">Pack Capacity ↓</option>
</select>
</div>
</div>
{/* Rides Cards Grid */}
{filteredRides.length === 0 ? (
<div className="bg-surface-container-low p-space-2xl rounded-2xl text-center border border-outline-variant/30 my-8">
<span className="material-symbols-outlined text-[48px] text-on-surface-variant mb-2">
search_off
</span>
<h3 className="font-headline-md text-headline-md text-on-surface mb-2">No Matching Pack Runs</h3>
<p className="font-body-md text-body-md text-on-surface-variant max-w-md mx-auto mb-4">
No group rides matched your search for &quot;{searchQuery}&quot;. Try adjusting your filters or organize a new group ride.
</p>
<button
onClick={() => {
setSearchQuery('');
setFilterPace('all');
}}
className="px-space-lg py-2 rounded-xl bg-surface-container-high text-primary font-headline-sm text-headline-sm hover:bg-surface-container-highest transition-colors"
>
Reset Filters
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-lg">
{filteredRides.map((ride) => {
const isFull = ride.participantCount >= ride.participantLimit;
const isJoined = ride.userStatus === 'joined';
const isRequested = ride.userStatus === 'requested';
const isWaitlisted = ride.userStatus === 'waitlisted';
return (
<div
key={ride.id}
className="bg-surface-container-low rounded-2xl overflow-hidden hover:bg-surface-container transition-all flex flex-col justify-between shadow-xl border border-outline-variant/20 group"
data-testid={`ride-card-${ride.id}`}
>
<div>
{/* Card Header Banner */}
<div className="relative h-44 w-full bg-gradient-to-br from-[#0F1D27] to-[#0A131B] border-b border-outline-variant/20 overflow-hidden">
{/* Atmospheric decorative lines */}
<div className="absolute inset-0 opacity-20 pointer-events-none">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<path d="M 0 140 Q 150 40 300 120 T 600 60" fill="none" stroke="#00F2FE" strokeWidth="2" strokeDasharray="6 4" />
</svg>
</div>
<div className="absolute top-3 left-3 px-2.5 py-1 rounded-lg bg-surface/80 backdrop-blur font-label-caps text-label-caps text-secondary-fixed border border-secondary-fixed/30 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">calendar_today</span>
{ride.scheduledDate} • {ride.scheduledTime}
</div>
<div
className={`absolute top-3 right-3 px-2.5 py-1 rounded-lg font-label-caps text-label-caps font-bold ${
ride.paceClassification === 'Track Pace'
? 'bg-primary-container text-on-primary-container'
: ride.paceClassification === 'Brisk Pace'
? 'bg-surface-container-highest text-primary border border-primary/30'
: 'bg-secondary-container text-on-secondary-container'
}`}
>
{ride.paceClassification}
</div>
<div className="absolute bottom-3 left-3 right-3 flex items-center justify-between text-on-surface-variant font-code-mono text-code-mono text-xs bg-surface/60 backdrop-blur px-2.5 py-1 rounded-lg">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">location_on</span>
{ride.region}
</span>
<span className="text-primary font-bold">{ride.distance}</span>
</div>
</div>
{/* Card Body */}
<div className="p-space-lg">
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-xs group-hover:text-primary transition-colors">
{ride.title}
</h3>
<p className="font-body-sm text-body-sm text-on-surface-variant line-clamp-2 mb-space-md">
{ride.description}
</p>
<div className="text-xs font-code-mono text-on-surface-variant mb-space-md flex items-center gap-1 truncate">
<span className="material-symbols-outlined text-[14px] text-secondary-fixed">
flag
</span>
<span className="truncate">Rally: {ride.meetingPoint}</span>
</div>
{/* Lead Marshal & Participant Indicator */}
<div className="flex items-center justify-between pt-space-sm bg-surface-container-high/60 p-space-sm rounded-xl border border-outline-variant/20">
<div className="flex items-center gap-space-xs">
<div className="w-6 h-6 rounded-full bg-primary-fixed-dim flex items-center justify-center text-on-primary-fixed font-bold text-[11px]">
{ride.leadAvatarText}
</div>
<span className="font-code-mono text-code-mono text-on-surface">
Lead: {ride.leadHandle}
</span>
</div>
<div
className={`flex items-center gap-1 font-code-mono text-code-mono ${
isFull ? 'text-error font-bold' : 'text-secondary-fixed'
}`}
>
<span className="material-symbols-outlined text-[16px]">group</span>
<span>
{isFull
? `Full (${ride.participantCount}/${ride.participantLimit})`
: `${ride.participantCount}/${ride.participantLimit} Riders`}
</span>
</div>
</div>
</div>
</div>
{/* Action Button */}
<div className="p-space-lg pt-0">
{isJoined ? (
<button
disabled
className="w-full py-2.5 rounded-xl bg-secondary-fixed/20 border border-secondary-fixed/40 text-secondary-fixed font-headline-sm text-headline-sm flex items-center justify-center gap-space-xs cursor-default"
>
<span className="material-symbols-outlined text-[18px]">check_circle</span>
Joined Pack (Slot Confirmed)
</button>
) : isRequested ? (
<button
disabled
className="w-full py-2.5 rounded-xl bg-primary-container/20 border border-primary-container/40 text-primary-container font-headline-sm text-headline-sm flex items-center justify-center gap-space-xs cursor-default"
>
<span className="material-symbols-outlined text-[18px]">pending</span>
Slot Requested (Pending Lead)
</button>
) : isWaitlisted ? (
<button
disabled
className="w-full py-2.5 rounded-xl bg-surface-container text-on-surface-variant font-headline-sm text-headline-sm flex items-center justify-center gap-space-xs cursor-default"
>
<span className="material-symbols-outlined text-[18px]">hourglass_top</span>
Waitlisted (Standby)
</button>
) : isFull ? (
<button
onClick={() => onRequestSlot(ride.id)}
className="w-full py-2.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface-variant hover:text-on-surface font-headline-sm text-headline-sm transition-all flex items-center justify-center gap-space-xs"
>
<span className="material-symbols-outlined text-[18px]">hourglass_top</span>
Join Waitlist ({ride.participantLimit} Cap)
</button>
) : (
<button
onClick={() => onRequestSlot(ride.id)}
className="w-full py-2.5 rounded-xl bg-surface-container-high hover:bg-primary-container hover:text-on-primary-container text-on-surface font-headline-sm text-headline-sm transition-all flex items-center justify-center gap-space-xs shadow-sm hover:shadow-[0_0_16px_rgba(0,242,254,0.3)]"
>
<span className="material-symbols-outlined text-[18px]">lock_open</span>
Request Pack Slot
</button>
)}
</div>
</div>
);
})}
</div>
)}
</section>
);
}