223 lines
8.9 KiB
TypeScript
223 lines
8.9 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { ClubUpcomingRide, UserPortalPermissions } from './types';
|
|
import { EmptyState } from './empty-state';
|
|
|
|
interface UpcomingRidesSectionProps {
|
|
rides: ClubUpcomingRide[];
|
|
permissions: UserPortalPermissions;
|
|
onPlanRide?: () => void;
|
|
onManageRide?: (rideId: string) => void;
|
|
onViewRoster?: (rideId: string) => void;
|
|
onViewRoute?: (routeId?: string) => void;
|
|
}
|
|
|
|
export function ClubRideCard({
|
|
ride,
|
|
permissions,
|
|
onManageRide,
|
|
onViewRoster,
|
|
onViewRoute,
|
|
}: {
|
|
ride: ClubUpcomingRide;
|
|
permissions: UserPortalPermissions;
|
|
onManageRide?: (rideId: string) => void;
|
|
onViewRoster?: (rideId: string) => void;
|
|
onViewRoute?: (routeId?: string) => void;
|
|
}) {
|
|
const capacityPercent = ride.capacity ? Math.min(100, Math.round((ride.confirmedCount / ride.capacity) * 100)) : 75;
|
|
|
|
return (
|
|
<div className="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 shadow-lg">
|
|
<div>
|
|
{/* Top Metadata Row */}
|
|
<div className="flex flex-wrap items-start justify-between gap-2 pb-2">
|
|
<div>
|
|
<div className="flex items-center gap-2 font-mono text-xs text-brand-cyan">
|
|
<span className="w-2 h-2 rounded-full bg-brand-cyan animate-pulse" />
|
|
<span>{ride.scheduledStartAt}</span>
|
|
<span className="text-slate-500">•</span>
|
|
<span className="text-slate-400">Ride ID: {ride.rideIdentifier}</span>
|
|
</div>
|
|
<h3 className="font-headline text-lg sm:text-xl font-bold text-white mt-1">
|
|
{ride.title}
|
|
</h3>
|
|
</div>
|
|
|
|
<span className="px-2.5 py-1 rounded-lg bg-surface-container-high text-slate-300 font-mono text-[11px] font-medium border border-brand-border/60">
|
|
{ride.paceClassification}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Attributes Row */}
|
|
<div className="grid grid-cols-3 gap-2 py-2.5 bg-surface-container-low/80 rounded-xl px-3.5 my-3 border border-brand-border/60">
|
|
<div>
|
|
<div className="font-mono text-[10px] text-slate-400">Distance</div>
|
|
<div className="font-headline text-sm font-semibold text-slate-100 mt-0.5">
|
|
{ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-mono text-[10px] text-slate-400">Duration</div>
|
|
<div className="font-headline text-sm font-semibold text-slate-100 mt-0.5">
|
|
{ride.durationString ?? '--'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-mono text-[10px] text-slate-400">Pace Classification</div>
|
|
<div className="font-headline text-sm font-semibold text-brand-cyan mt-0.5">
|
|
{ride.paceClassification}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Roster & Road Captain Details */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between font-mono text-xs">
|
|
<span className="text-slate-400">Riders: {ride.confirmedCount} going</span>
|
|
{ride.pendingCount > 0 ? (
|
|
<span className="text-amber-400 font-medium">{ride.pendingCount} requests pending</span>
|
|
) : ride.capacity ? (
|
|
<span className="text-brand-cyan font-medium">
|
|
{Math.max(0, ride.capacity - ride.confirmedCount)} open spots
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="w-full h-1.5 rounded bg-surface-container-high overflow-hidden" aria-hidden="true">
|
|
<div
|
|
className={`h-full rounded transition-all duration-300 ${
|
|
ride.pendingCount > 0 ? 'bg-amber-400' : 'bg-brand-cyan'
|
|
}`}
|
|
style={{ width: `${capacityPercent}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between pt-1 font-body text-xs text-slate-300">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-slate-400">Road Captain:</span>
|
|
<span className="text-slate-100 font-medium">{ride.roadCaptainName}</span>
|
|
{ride.roadCaptainMotorcycle && (
|
|
<span className="font-mono text-[11px] text-slate-400">
|
|
({ride.roadCaptainMotorcycle})
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{ride.sweepStatus === 'needs_assignment' ? (
|
|
<div className="font-mono text-xs text-error font-medium">Sweep: Needs Assignment</div>
|
|
) : ride.briefingTime ? (
|
|
<div className="font-mono text-xs text-brand-cyan">Briefing: {ride.briefingTime}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 15: Verbatim Live Location Privacy Policy Notice */}
|
|
<div className="mt-3.5 p-2.5 rounded-xl bg-surface-container/60 border border-brand-border/60 text-slate-400 font-body text-[11px] flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-[15px] text-brand-cyan shrink-0 mt-0.5">
|
|
info
|
|
</span>
|
|
<span>
|
|
Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="mt-4 pt-3 border-t border-brand-border/60 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{permissions.canPlanRides ? (
|
|
<button
|
|
onClick={() => onManageRide?.(ride.id)}
|
|
className="h-9 px-3.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-sm transition-all cursor-pointer"
|
|
>
|
|
Manage Ride
|
|
</button>
|
|
) : (
|
|
<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"
|
|
>
|
|
View Ride
|
|
</button>
|
|
)}
|
|
|
|
<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"
|
|
>
|
|
View Roster ({ride.confirmedCount})
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => onViewRoute?.(ride.routeId ?? undefined)}
|
|
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-300 hover:text-white font-body text-xs flex items-center gap-1.5 transition-colors cursor-pointer"
|
|
>
|
|
<span className="material-symbols-outlined text-[15px] text-brand-cyan">map</span>
|
|
<span>View Route</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UpcomingRidesSection({
|
|
rides,
|
|
permissions,
|
|
onPlanRide,
|
|
onManageRide,
|
|
onViewRoster,
|
|
onViewRoute,
|
|
}: UpcomingRidesSectionProps) {
|
|
return (
|
|
<section className="space-y-4" aria-label="Upcoming Club Rides">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="font-headline text-base font-semibold text-on-surface tracking-tight">
|
|
Upcoming Club Rides
|
|
</h2>
|
|
<p className="font-body text-xs text-outline">
|
|
Scheduled Club Rides, routes, participants, and ride assignments.
|
|
</p>
|
|
</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>Full Ride Schedule</span>
|
|
<span className="material-symbols-outlined text-[13px]">arrow_forward</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{rides.length === 0 ? (
|
|
<EmptyState
|
|
icon="route"
|
|
title="No Club Rides are currently scheduled."
|
|
description="Plan a group route, designate pacelines, and invite club riders."
|
|
actionLabel={permissions.canPlanRides ? 'Plan Club Ride' : undefined}
|
|
onAction={permissions.canPlanRides ? onPlanRide : undefined}
|
|
className="py-10"
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
{rides.map((ride) => (
|
|
<ClubRideCard
|
|
key={ride.id}
|
|
ride={ride}
|
|
permissions={permissions}
|
|
onManageRide={onManageRide}
|
|
onViewRoster={onViewRoster}
|
|
onViewRoute={onViewRoute}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|