335 lines
16 KiB
TypeScript
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 & 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 "{searchQuery}". 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>
|
|
);
|
|
}
|