feat(website): build interactive group rides hub with live pack radar, filters, and modals
This commit is contained in:
parent
46ce052e0c
commit
67c25b5c50
|
|
@ -1,9 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { INITIAL_GROUP_RIDES } from '../../components/group-rides/fixtures';
|
||||
import { GroupRideSummary } from '../../components/group-rides/types';
|
||||
import { LivePackRadar } from '../../components/group-rides/live-pack-radar';
|
||||
import { GroupRidesDirectory } from '../../components/group-rides/group-rides-directory';
|
||||
import { JoinRideModal } from '../../components/group-rides/join-ride-modal';
|
||||
import { CreateRideModal } from '../../components/group-rides/create-ride-modal';
|
||||
|
||||
export default function GroupRidesPage() {
|
||||
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
|
||||
const [isJoinModalOpen, setIsJoinModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [activeAlert, setActiveAlert] = useState<{ message: string; type: 'success' | 'info' } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const showAlert = (message: string, type: 'success' | 'info' = 'success') => {
|
||||
setActiveAlert({ message, type });
|
||||
setTimeout(() => {
|
||||
setActiveAlert(null);
|
||||
}, 4500);
|
||||
};
|
||||
|
||||
const handleJoinSuccess = (code: string, rideTitle: string) => {
|
||||
showAlert(`Successfully synchronized telemetry with "${rideTitle}" (Code: ${code})!`, 'success');
|
||||
};
|
||||
|
||||
const handleCreateRide = (newRide: GroupRideSummary) => {
|
||||
setRides((prev) => [newRide, ...prev]);
|
||||
showAlert(`Published group run "${newRide.title}"! Pack radar is now active.`, 'success');
|
||||
};
|
||||
|
||||
const handleRequestSlot = (rideId: string) => {
|
||||
setRides((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.id !== rideId) return r;
|
||||
if (r.participantCount >= r.participantLimit) {
|
||||
showAlert(`Added to waitlist for "${r.title}". You will be alerted if a slot opens.`, 'info');
|
||||
return { ...r, userStatus: 'waitlisted' };
|
||||
}
|
||||
showAlert(`Slot requested for "${r.title}". Telemetry sync confirmed with Lead Marshal!`, 'success');
|
||||
return {
|
||||
...r,
|
||||
userStatus: 'joined',
|
||||
participantCount: r.participantCount + 1,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flagship-page text-on-surface bg-surface min-h-screen">
|
||||
<div className="flex flex-col w-full">
|
||||
|
|
@ -11,6 +57,30 @@ export default function GroupRidesPage() {
|
|||
<div className="relative w-full overflow-hidden">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-container/10 rounded-full blur-[120px] pointer-events-none -z-10"></div>
|
||||
<div className="absolute top-64 right-10 w-[500px] h-[500px] bg-secondary-fixed/5 rounded-full blur-[140px] pointer-events-none -z-10"></div>
|
||||
|
||||
{/* Toast Notification Alert */}
|
||||
{activeAlert && (
|
||||
<div
|
||||
className={`fixed top-20 right-6 z-50 px-5 py-3 rounded-xl shadow-2xl flex items-center gap-3 border backdrop-blur-md transition-all animate-bounce ${
|
||||
activeAlert.type === 'success'
|
||||
? 'bg-secondary-container/90 text-on-secondary-container border-secondary-fixed/40'
|
||||
: 'bg-surface-container-high/90 text-primary border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
{activeAlert.type === 'success' ? 'check_circle' : 'info'}
|
||||
</span>
|
||||
<span className="font-code-mono text-sm font-semibold">{activeAlert.message}</span>
|
||||
<button
|
||||
onClick={() => setActiveAlert(null)}
|
||||
className="ml-2 hover:opacity-75"
|
||||
title="Dismiss"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="max-w-container-max mx-auto px-gutter-desktop pt-space-2xl pb-space-3xl">
|
||||
<div className="flex flex-col items-start max-w-4xl">
|
||||
|
|
@ -20,377 +90,189 @@ export default function GroupRidesPage() {
|
|||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-secondary-fixed opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-secondary-fixed-dim"></span>
|
||||
</span>
|
||||
<span className="font-label-caps text-label-caps text-secondary-fixed tracking-wide">Live Pack Radar & Formation Telemetry</span>
|
||||
<span className="font-label-caps text-label-caps text-secondary-fixed tracking-wide">
|
||||
Live Pack Radar & Formation Telemetry
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="font-display-hero text-display-hero text-on-surface tracking-tight mb-space-sm">
|
||||
Ride in pack formation. <br />
|
||||
<span className="bg-gradient-to-r from-primary-container via-primary-fixed to-secondary-fixed text-transparent bg-clip-text">Never lose a wingman.</span>
|
||||
<span className="bg-gradient-to-r from-primary-container via-primary-fixed to-secondary-fixed text-transparent bg-clip-text">
|
||||
Never lose a wingman.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheading */}
|
||||
<p className="font-body-lg text-body-lg text-on-surface-variant max-w-2xl mb-space-xl">
|
||||
Real-time rider radar, staggered formation spacing alerts, instant incident broadcast, and automated regroup checkpoints calibrated for fast canyon runs and transcontinental tours.
|
||||
Real-time rider radar, staggered formation spacing alerts, instant incident broadcast, and
|
||||
automated regroup checkpoints calibrated for fast canyon runs and transcontinental tours.
|
||||
</p>
|
||||
|
||||
{/* Dual CTAs & Quick Metrics Bar */}
|
||||
<div className="flex flex-wrap items-center gap-space-md w-full">
|
||||
<button className="inline-flex items-center justify-center gap-space-xs px-space-xl py-3.5 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm shadow-xl hover:shadow-[0_0_24px_rgba(0,242,254,0.4)] transition-all" id="btn-create-ride">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="inline-flex items-center justify-center gap-space-xs px-space-xl py-3.5 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm shadow-xl hover:shadow-[0_0_24px_rgba(0,242,254,0.4)] transition-all cursor-pointer"
|
||||
id="btn-create-ride"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">add_circle</span>
|
||||
Create Group Ride
|
||||
</button>
|
||||
<button className="inline-flex items-center justify-center gap-space-xs px-space-lg py-3.5 rounded-xl bg-surface-container-high hover:bg-surface-container-highest text-on-surface font-headline-sm text-headline-sm transition-all" id="btn-join-code">
|
||||
<span className="material-symbols-outlined text-primary-fixed-dim text-[20px]">qr_code_scanner</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsJoinModalOpen(true)}
|
||||
className="inline-flex items-center justify-center gap-space-xs px-space-lg py-3.5 rounded-xl bg-surface-container-high hover:bg-surface-container-highest text-on-surface font-headline-sm text-headline-sm transition-all cursor-pointer border border-outline-variant/30"
|
||||
id="btn-join-code"
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary-fixed-dim text-[20px]">
|
||||
qr_code_scanner
|
||||
</span>
|
||||
Join with Ride Code / QR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Interactive Live Pack Radar Map & Dashboard */}
|
||||
|
||||
{/* Interactive Live Pack Radar Cockpit */}
|
||||
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||
<div className="bg-surface-container-lowest rounded-xl shadow-2xl overflow-hidden">
|
||||
{/* Tactical Header Strip */}
|
||||
<div className="bg-surface-container-low px-space-lg py-space-sm flex flex-wrap items-center justify-between gap-space-md">
|
||||
<div className="flex items-center gap-space-md">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="h-2 w-2 rounded-full bg-secondary-fixed"></span>
|
||||
<span className="font-label-caps text-label-caps text-on-surface tracking-wide">Swarm Recon: Skyline Blvd > Coast Hwy</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant bg-surface-container px-2 py-0.5 rounded">Session #CA-84-SW</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-lg font-code-mono text-code-mono text-on-surface-variant">
|
||||
<span className="flex items-center gap-1 hidden lg:flex">
|
||||
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">cell_tower</span> Mesh Active
|
||||
</span>
|
||||
<span className="flex items-center gap-1"><span className="text-secondary-fixed">●</span> 6 Synchronized</span>
|
||||
<span className="hidden sm:inline">Gap Delta: ±0.4s</span>
|
||||
<span className="text-primary-container">Repair Squad: Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Cockpit Layout: Map & Radar Feed */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0">
|
||||
{/* Tactical Canvas & Radar Screen */}
|
||||
<div className="lg:col-span-8 relative bg-surface-container-low min-h-[540px] flex flex-col justify-between p-space-md overflow-hidden">
|
||||
{/* Map background placeholder with tactical overlay styling */}
|
||||
<div className="absolute inset-0 bg-cover bg-center opacity-40 mix-blend-luminosity" data-location="Skyline Boulevard, Woodside, CA" ></div>
|
||||
{/* Tactical Coordinate Grids (Inline SVG Overlay) */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-30" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern height="48" id="grid-pattern" patternUnits="userSpaceOnUse" width="48">
|
||||
<path className="text-outline-variant" d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="0.5"></path>
|
||||
<circle className="text-primary-fixed-dim" cx="0" cy="0" fill="currentColor" r="1.5"></circle>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect fill="url(#grid-pattern)" height="100%" width="100%"></rect>
|
||||
{/* Tactical Route Trace */}
|
||||
<path className="text-primary-container" d="M 120 460 C 220 380, 260 410, 360 300 S 490 260, 560 170 S 710 160, 840 90" fill="none" stroke="currentColor" strokeDasharray="8 6" strokeWidth="4"></path>
|
||||
{/* Safe Spacing Buffers */}
|
||||
<circle className="text-secondary-fixed/40" cx="840" cy="90" fill="none" r="32" stroke="currentColor" strokeWidth="1.5"></circle>
|
||||
<circle className="text-secondary-fixed/40" cx="710" cy="160" fill="none" r="28" stroke="currentColor" strokeWidth="1.5"></circle>
|
||||
<circle className="text-error/50" cx="560" cy="170" fill="none" r="32" stroke="currentColor" strokeWidth="2"></circle>
|
||||
<circle className="text-secondary-fixed/40" cx="360" cy="300" fill="none" r="26" stroke="currentColor" strokeWidth="1.5"></circle>
|
||||
<circle className="text-error-container" cx="260" cy="410" fill="none" r="36" stroke="currentColor" strokeDasharray="4 2" strokeWidth="2.5"></circle>
|
||||
<circle className="text-primary-container/50" cx="120" cy="460" fill="none" r="30" stroke="currentColor" strokeWidth="1.5"></circle>
|
||||
</svg>
|
||||
{/* Map HUD Top Left: Sector Status */}
|
||||
<div className="relative z-10 flex items-start justify-between">
|
||||
<div className="bg-surface/90 backdrop-blur-md px-space-md py-2.5 rounded-xl shadow-lg flex flex-col gap-0.5">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Formation Profile</span>
|
||||
<span className="font-headline-sm text-headline-sm text-primary">Staggered Dual Column [B]</span>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed">Paceline Lock: Optimal</span>
|
||||
</div>
|
||||
{/* Map HUD Top Right: Telemetry Ping */}
|
||||
<div className="bg-surface/90 backdrop-blur-md px-space-md py-2 rounded-xl shadow-lg flex items-center gap-space-sm">
|
||||
<div className="text-right">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant block">GPS Frequency</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">10.0 Hz • GNSS Lock</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-secondary-fixed text-[24px]">satellite_alt</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Visual Pin Indicators plotted across the canyon trace */}
|
||||
<div className="relative z-10 my-auto pointer-events-none">
|
||||
{/* Lead Rider Marker */}
|
||||
<div className="absolute -top-32 right-12 flex items-center gap-space-xs bg-surface-container-high/90 px-2.5 py-1 rounded shadow-md pointer-events-auto cursor-pointer hover:bg-surface-bright">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-secondary-fixed animate-pulse"></span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface font-bold">1 • ApexLead</span>
|
||||
<span className="font-label-caps text-label-caps text-secondary-fixed">68 MPH</span>
|
||||
</div>
|
||||
{/* Hard Braking Warning Marker (Rider 5) */}
|
||||
<div className="absolute bottom-12 left-1/3 flex items-center gap-space-xs bg-error-container/90 px-2.5 py-1.5 rounded shadow-xl pointer-events-auto">
|
||||
<span className="material-symbols-outlined text-error text-[16px] animate-bounce">warning</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface font-bold">5 • Alex_Ducati</span>
|
||||
<span className="font-label-caps text-label-caps text-error">-1.2G Decel</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Map HUD Bottom Control Strip */}
|
||||
<div className="relative z-10 flex flex-wrap items-center justify-between gap-space-sm mt-auto pt-space-lg">
|
||||
<div className="flex items-center gap-space-xs bg-surface/90 backdrop-blur-md p-1.5 rounded-xl">
|
||||
<button className="px-space-sm py-1 rounded-lg bg-surface-container-highest text-primary font-label-caps text-label-caps">Tactical</button>
|
||||
<button className="px-space-sm py-1 rounded-lg hover:bg-surface-container text-on-surface-variant font-label-caps text-label-caps">Satellite</button>
|
||||
<button className="px-space-sm py-1 rounded-lg hover:bg-surface-container text-on-surface-variant font-label-caps text-label-caps">Elevation 3D</button>
|
||||
</div>
|
||||
<div className="bg-surface/90 backdrop-blur-md px-space-md py-1.5 rounded-xl flex items-center gap-space-sm">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Regroup Point</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-container font-bold">Alice's Restaurant (14.8 mi)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Live Swarm Nodes Telemetry Column */}
|
||||
<div className="lg:col-span-4 bg-surface-container p-space-md flex flex-col gap-space-sm overflow-y-auto max-h-[620px]">
|
||||
<div className="flex items-center justify-between pb-space-xs mb-space-xs">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant tracking-wide">Pack Telemetry Stream</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim">6 Active</span>
|
||||
</div>
|
||||
{/* Rider 1: ApexLead */}
|
||||
<div className="p-space-sm rounded-xl bg-surface-container-high hover:bg-surface-container-highest transition-all">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-secondary-fixed"></span>
|
||||
<span className="font-headline-sm text-headline-sm text-on-surface">ApexLead</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-secondary-fixed">Pace Setter</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed">Lead</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 font-code-mono text-code-mono text-on-surface-variant">
|
||||
<div>Speed: <span className="text-on-surface font-bold">68 MPH</span></div>
|
||||
<div>Lean: <span className="text-primary-container font-bold">42°</span></div>
|
||||
<div>Throttle: <span className="text-on-surface">78%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rider 2: Ghost99 */}
|
||||
<div className="p-space-sm rounded-xl bg-surface-container-high hover:bg-surface-container-highest transition-all">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-secondary-fixed"></span>
|
||||
<span className="font-headline-sm text-headline-sm text-on-surface">Ghost99</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-on-surface-variant">Wingman L</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed">+45m Gap</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 font-code-mono text-code-mono text-on-surface-variant">
|
||||
<div>Speed: <span className="text-on-surface">67 MPH</span></div>
|
||||
<div>Lean: <span className="text-primary-container font-bold">40°</span></div>
|
||||
<div>Brake: <span className="text-on-surface">0%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rider 3: Sarah_RR (Warning: Tire Pressure) */}
|
||||
<div className="p-space-sm rounded-xl bg-surface-container-high hover:bg-surface-container-highest transition-all relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-error"></div>
|
||||
<div className="flex items-center justify-between mb-1 pl-1">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-error animate-pulse"></span>
|
||||
<span className="font-headline-sm text-headline-sm text-on-surface">Sarah_RR</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-on-surface-variant">Wingman R</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">+110m Gap</span>
|
||||
</div>
|
||||
<div className="mt-2 pl-1">
|
||||
<div className="flex items-center gap-1 text-error font-code-mono text-code-mono">
|
||||
<span className="material-symbols-outlined text-[15px]">tire_repair</span>
|
||||
<span>Alert: Rear tire 28 PSI (low)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rider 4: Dan_KTM */}
|
||||
<div className="p-space-sm rounded-xl bg-surface-container-high hover:bg-surface-container-highest transition-all">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-secondary-fixed"></span>
|
||||
<span className="font-headline-sm text-headline-sm text-on-surface">Dan_KTM</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-on-surface-variant">Mid Pack</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed">+180m Gap</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 font-code-mono text-code-mono text-on-surface-variant">
|
||||
<div>Speed: <span className="text-on-surface">63 MPH</span></div>
|
||||
<div>Lean: <span className="text-on-surface">28°</span></div>
|
||||
<div>Temp: <span className="text-on-surface">88°C</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rider 5: Alex_Ducati (Yellow Flag: Hard Braking) */}
|
||||
<div className="p-space-sm rounded-xl bg-surface-container-high hover:bg-surface-container-highest transition-all relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-error-container"></div>
|
||||
<div className="flex items-center justify-between mb-1 pl-1">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-error"></span>
|
||||
<span className="font-headline-sm text-headline-sm text-on-surface">Alex_Ducati</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-error">Decel Event</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">+320m Gap</span>
|
||||
</div>
|
||||
<div className="mt-2 pl-1 flex items-center justify-between font-code-mono text-code-mono">
|
||||
<span className="text-error font-bold">Hard Braking (-1.2G)</span>
|
||||
<span className="text-on-surface-variant">ABS: Engaged</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rider 6: Sweep_Marshal */}
|
||||
<div className="p-space-sm rounded-xl bg-surface-container-high hover:bg-surface-container-highest transition-all">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-primary-container"></span>
|
||||
<span className="font-headline-sm text-headline-sm text-on-surface">Sweep_Marshal</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-primary-container">Rear Guard</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim">+450m Gap</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 font-code-mono text-code-mono text-on-surface-variant">
|
||||
<div>Speed: <span className="text-on-surface">59 MPH</span></div>
|
||||
<div>Radio: <span className="text-secondary-fixed">Open</span></div>
|
||||
<div>Pack Lock: <span className="text-primary">100%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Formation Safety Metrics Panel Strip */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 bg-surface-container-highest p-space-lg gap-space-lg">
|
||||
{/* Metric 1: Spacing Integrity */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Spacing Integrity</span>
|
||||
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-secondary-fixed">94%</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">Safety Index</span>
|
||||
</div>
|
||||
<div className="w-full bg-surface-container h-1.5 rounded-full overflow-hidden mt-1">
|
||||
<div className="bg-secondary-fixed h-full rounded-full" ></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Metric 2: Average Pack Speed */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Average Pack Velocity</span>
|
||||
<span className="material-symbols-outlined text-primary-container text-[18px]">speed</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-on-surface">64.2</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim">MPH</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">Delta Drift: -1.8 MPH (tightening)</span>
|
||||
</div>
|
||||
{/* Metric 3: Atmospheric & Track Conditions */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Surface & Climate</span>
|
||||
<span className="material-symbols-outlined text-on-surface-variant text-[18px]">thermostat</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-on-surface">21°C</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">Dry Asphalt</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed">0% Precip • Grip Optimal</span>
|
||||
</div>
|
||||
{/* Metric 4: Next Regroup Waypoint */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Regroup Point</span>
|
||||
<span className="material-symbols-outlined text-primary-container text-[18px]">pin_drop</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-primary-container">14.8</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim">Miles</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Alice's Restaurant Checkpoint</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LivePackRadar initialMode="tactical" />
|
||||
</section>
|
||||
|
||||
{/* Group Ride Features Grid */}
|
||||
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||
<div className="flex flex-col mb-space-2xl">
|
||||
<span className="font-label-caps text-label-caps text-primary-container tracking-wide mb-1">Cockpit Architecture</span>
|
||||
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">Engineered for Squadron Cohesion</h2>
|
||||
<p className="font-body-md text-body-md text-on-surface-variant max-w-2xl">High-frequency mesh communications keep the entire formation synchronized through blind apexes, switchbacks, and offline mountain passes.</p>
|
||||
<span className="font-label-caps text-label-caps text-primary-container tracking-wide mb-1">
|
||||
Cockpit Architecture
|
||||
</span>
|
||||
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
|
||||
Engineered for Squadron Cohesion
|
||||
</h2>
|
||||
<p className="font-body-md text-body-md text-on-surface-variant max-w-2xl">
|
||||
High-frequency mesh communications keep the entire formation synchronized through blind
|
||||
apexes, switchbacks, and offline mountain passes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-space-lg">
|
||||
{/* Feature 1 */}
|
||||
<div className="bg-surface-container-low p-space-xl rounded-xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md">
|
||||
<div className="bg-surface-container-low p-space-xl rounded-2xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md border border-outline-variant/20">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-xl bg-surface-container-high flex items-center justify-center text-primary-container mb-space-lg">
|
||||
<span className="material-symbols-outlined text-[26px]">spatial_audio_off</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim block mb-1">01 / Paceline Audit</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">Staggered Paceline Warnings</h3>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim block mb-1">
|
||||
01 / Paceline Audit
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
Staggered Paceline Warnings
|
||||
</h3>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||
Spatial audio HUD chimes dynamically sound whenever rider follow distance or lane positioning falls beneath calculated emergency stopping thresholds.
|
||||
Spatial audio HUD chimes dynamically sound whenever rider follow distance or lane
|
||||
positioning falls beneath calculated emergency stopping thresholds.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-primary-container">
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-primary-container border-t border-outline-variant/20">
|
||||
<span>Response: <12ms</span>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<div className="bg-surface-container-low p-space-xl rounded-xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md">
|
||||
<div className="bg-surface-container-low p-space-xl rounded-2xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md border border-outline-variant/20">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-xl bg-surface-container-high flex items-center justify-center text-error mb-space-lg">
|
||||
<span className="material-symbols-outlined text-[26px]">fmd_bad</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-error block mb-1">02 / Swarm Broadcast</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">Crash & Separation Alerts</h3>
|
||||
<span className="font-code-mono text-code-mono text-error block mb-1">
|
||||
02 / Swarm Broadcast
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
Crash & Separation Alerts
|
||||
</h3>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||
Autonomous IMU-based impact detection pings the entire pack instantly. Visual yellow and red telemetry flags highlight precise coordinates for immediate regrouping.
|
||||
Autonomous IMU-based impact detection pings the entire pack instantly. Visual yellow and
|
||||
red telemetry flags highlight precise coordinates for immediate regrouping.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-error">
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-error border-t border-outline-variant/20">
|
||||
<span>IMU Sensing: 6-Axis</span>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<div className="bg-surface-container-low p-space-xl rounded-xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md">
|
||||
<div className="bg-surface-container-low p-space-xl rounded-2xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md border border-outline-variant/20">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-xl bg-surface-container-high flex items-center justify-center text-secondary-fixed mb-space-lg">
|
||||
<span className="material-symbols-outlined text-[26px]">hub</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed block mb-1">03 / Offline Link</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">Peer-to-Peer Mesh Sync</h3>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed block mb-1">
|
||||
03 / Offline Link
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
Peer-to-Peer Mesh Sync
|
||||
</h3>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||
Never rely on cellular towers in deep canyons. High-throughput ad-hoc Bluetooth and Wi-Fi Direct protocols daisy-chain vital bike metrics from lead to sweep.
|
||||
Never rely on cellular towers in deep canyons. High-throughput ad-hoc Bluetooth and Wi-Fi
|
||||
Direct protocols daisy-chain vital bike metrics from lead to sweep.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-secondary-fixed">
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-secondary-fixed border-t border-outline-variant/20">
|
||||
<span>Zero-cell Ready</span>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 4 */}
|
||||
<div className="bg-surface-container-low p-space-xl rounded-xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md">
|
||||
<div className="bg-surface-container-low p-space-xl rounded-2xl flex flex-col justify-between hover:bg-surface-container transition-all shadow-md border border-outline-variant/20">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-xl bg-surface-container-high flex items-center justify-center text-primary-fixed mb-space-lg">
|
||||
<span className="material-symbols-outlined text-[26px]">history_toggle_off</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed block mb-1">04 / Mission Debrief</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">Group Replay & Debrief</h3>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed block mb-1">
|
||||
04 / Mission Debrief
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
Group Replay & Debrief
|
||||
</h3>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||
Relive the ride with synced multi-bike timelines. Compare apex apex entries, sector speeds, and individual lean angles on an interactive 3D map renderer.
|
||||
Relive the ride with synced multi-bike timelines. Compare apex entries, sector speeds,
|
||||
and individual lean angles on an interactive 3D map renderer.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-primary-fixed">
|
||||
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-primary-fixed border-t border-outline-variant/20">
|
||||
<span>3D Replay Engine</span>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Visual Divider / Action Interstitial */}
|
||||
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||
<div className="relative bg-surface-container-low rounded-xl overflow-hidden shadow-2xl p-space-2xl">
|
||||
<div className="relative bg-surface-container-low rounded-2xl overflow-hidden shadow-2xl p-space-2xl border border-outline-variant/20">
|
||||
<div className="absolute -right-16 -bottom-16 w-80 h-80 bg-primary-container/10 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-space-xl">
|
||||
<div className="flex flex-col gap-space-xs max-w-xl">
|
||||
<span className="font-label-caps text-label-caps text-secondary-fixed">Hardware Compatibility</span>
|
||||
<h3 className="font-headline-lg text-headline-lg text-on-surface tracking-tight">Sync directly with your helmet comms & bike dash</h3>
|
||||
<span className="font-label-caps text-label-caps text-secondary-fixed">
|
||||
Hardware Compatibility
|
||||
</span>
|
||||
<h3 className="font-headline-lg text-headline-lg text-on-surface tracking-tight">
|
||||
Sync directly with your helmet comms & bike dash
|
||||
</h3>
|
||||
<p className="font-body-md text-body-md text-on-surface-variant">
|
||||
Integrates seamlessly with Cardo, Sena, Apple Watch, and OBD-II CAN bus links for live peg-to-peg data overlays.
|
||||
Integrates seamlessly with Cardo, Sena, Apple Watch, and OBD-II CAN bus links for live
|
||||
peg-to-peg data overlays.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-md">
|
||||
<div className="flex items-center gap-space-sm px-space-md py-3 rounded-xl bg-surface-container-high">
|
||||
<div className="flex items-center gap-space-sm px-space-md py-3 rounded-xl bg-surface-container-high border border-outline-variant/20">
|
||||
<span className="material-symbols-outlined text-secondary-fixed">headset_mic</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Cardo / Sena HUD</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-sm px-space-md py-3 rounded-xl bg-surface-container-high">
|
||||
<div className="flex items-center gap-space-sm px-space-md py-3 rounded-xl bg-surface-container-high border border-outline-variant/20">
|
||||
<span className="material-symbols-outlined text-primary-container">watch</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Haptic Alerts</span>
|
||||
</div>
|
||||
|
|
@ -398,193 +280,23 @@ export default function GroupRidesPage() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Active Public & Regional Group Rides Directory */}
|
||||
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||
<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">
|
||||
<button className="px-space-md py-1.5 rounded-lg bg-surface-container-highest text-on-surface font-label-md text-label-md">All Runs (14)</button>
|
||||
<button className="px-space-md py-1.5 rounded-lg text-on-surface-variant hover:text-on-surface font-label-md text-label-md">Brisk Pace</button>
|
||||
<button className="px-space-md py-1.5 rounded-lg text-on-surface-variant hover:text-on-surface font-label-md text-label-md">Track / Apex</button>
|
||||
<button className="px-space-md py-1.5 rounded-lg text-on-surface-variant hover:text-on-surface font-label-md text-label-md">Weekend Tours</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">
|
||||
<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 className="w-full bg-transparent font-body-md text-body-md text-on-surface placeholder:text-on-surface-variant outline-none" placeholder="Search by region, canyon, or road marshal..." type="text"/>
|
||||
</div>
|
||||
<div className="flex items-center gap-space-sm font-code-mono text-code-mono text-on-surface-variant">
|
||||
<span>Sort by:</span>
|
||||
<span className="text-primary-container cursor-pointer font-bold">Start Time ↓</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rides Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-lg">
|
||||
{/* Ride 1 */}
|
||||
<div className="bg-surface-container-low rounded-xl overflow-hidden hover:bg-surface-container transition-all flex flex-col justify-between shadow-xl">
|
||||
<div>
|
||||
<div className="relative h-44 w-full">
|
||||
<div className="w-full h-full bg-cover bg-center" data-alt="High performance sport motorcycles carving through early morning mist on California mountain ridge highway with sweeping Pacific ocean view in dark cinematic tones" ></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-surface-container-low via-surface-container-low/40 to-transparent"></div>
|
||||
<div className="absolute top-3 left-3 px-2 py-1 rounded bg-surface/80 backdrop-blur font-label-caps text-label-caps text-secondary-fixed">
|
||||
Saturday • 06:30 AM PST
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 px-2 py-1 rounded bg-primary-container text-on-primary-container font-label-caps text-label-caps font-bold">
|
||||
Track Pace
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-space-lg">
|
||||
<div className="flex items-center justify-between text-on-surface-variant font-code-mono text-code-mono mb-1">
|
||||
<span>San Francisco, CA</span>
|
||||
<span>82.4 Miles</span>
|
||||
</div>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-xs">Bay Area Dawn Patrol</h3>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant line-clamp-2 mb-space-md">
|
||||
Fast sweepers from Woodside down to San Gregorio beach before morning traffic. Strict staggered formation enforced.
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-space-sm bg-surface-container-high/60 p-space-sm rounded-xl">
|
||||
<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]">AL</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Lead: ApexLead</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 font-code-mono text-code-mono text-secondary-fixed">
|
||||
<span className="material-symbols-outlined text-[16px]">group</span>
|
||||
<span>14/16 Riders</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-space-lg pt-0">
|
||||
<button className="w-full py-2.5 rounded-lg 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">
|
||||
<span className="material-symbols-outlined text-[18px]">lock_open</span>
|
||||
Request Pack Slot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Ride 2 */}
|
||||
<div className="bg-surface-container-low rounded-xl overflow-hidden hover:bg-surface-container transition-all flex flex-col justify-between shadow-xl">
|
||||
<div>
|
||||
<div className="relative h-44 w-full">
|
||||
<div className="w-full h-full bg-cover bg-center" data-alt="Trio of supernaked Italian motorcycles parked at golden hour canyon overlook with asphalt switchbacks carving through dark mountains" ></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-surface-container-low via-surface-container-low/40 to-transparent"></div>
|
||||
<div className="absolute top-3 left-3 px-2 py-1 rounded bg-surface/80 backdrop-blur font-label-caps text-label-caps text-secondary-fixed">
|
||||
Sunday • 07:15 AM PST
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 px-2 py-1 rounded bg-surface-container-highest text-primary font-label-caps text-label-caps font-bold">
|
||||
Brisk Pace
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-space-lg">
|
||||
<div className="flex items-center justify-between text-on-surface-variant font-code-mono text-code-mono mb-1">
|
||||
<span>Malibu, CA</span>
|
||||
<span>64.0 Miles</span>
|
||||
</div>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-xs">Malibu Canyons Fast Pace</h3>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant line-clamp-2 mb-space-md">
|
||||
Tuna Canyon descent to Latigo Canyon technical curves. Regroup checkpoint planned at Rock Store overlook.
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-space-sm bg-surface-container-high/60 p-space-sm rounded-xl">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<div className="w-6 h-6 rounded-full bg-tertiary-fixed-dim flex items-center justify-center text-on-tertiary font-bold text-[11px]">DK</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Lead: Dan_KTM</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 font-code-mono text-code-mono text-secondary-fixed">
|
||||
<span className="material-symbols-outlined text-[16px]">group</span>
|
||||
<span>9/12 Riders</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-space-lg pt-0">
|
||||
<button className="w-full py-2.5 rounded-lg 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">
|
||||
<span className="material-symbols-outlined text-[18px]">lock_open</span>
|
||||
Request Pack Slot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Ride 3 */}
|
||||
<div className="bg-surface-container-low rounded-xl overflow-hidden hover:bg-surface-container transition-all flex flex-col justify-between shadow-xl">
|
||||
<div>
|
||||
<div className="relative h-44 w-full">
|
||||
<div className="w-full h-full bg-cover bg-center" data-alt="Dense green mountain pass winding asphalt curve with neon trail lights of aggressive sport bikes in moody dark atmospheric lighting" ></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-surface-container-low via-surface-container-low/40 to-transparent"></div>
|
||||
<div className="absolute top-3 left-3 px-2 py-1 rounded bg-surface/80 backdrop-blur font-label-caps text-label-caps text-secondary-fixed">
|
||||
Fri-Sun • Oct 12-14
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 px-2 py-1 rounded bg-secondary-container text-on-secondary-container font-label-caps text-label-caps font-bold">
|
||||
Endurance / Chill
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-space-lg">
|
||||
<div className="flex items-center justify-between text-on-surface-variant font-code-mono text-code-mono mb-1">
|
||||
<span>Robbinsville, NC</span>
|
||||
<span>318 Curves</span>
|
||||
</div>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-xs">Tail of the Dragon Run</h3>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant line-clamp-2 mb-space-md">
|
||||
11 miles, 318 technical curves. Telemetry debrief sessions every evening with custom apex analysis and gear logs.
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-space-sm bg-surface-container-high/60 p-space-sm rounded-xl">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<div className="w-6 h-6 rounded-full bg-secondary-fixed flex items-center justify-center text-on-secondary-fixed font-bold text-[11px]">SM</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Lead: Sweep_Marshal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 font-code-mono text-code-mono text-error">
|
||||
<span className="material-symbols-outlined text-[16px]">group</span>
|
||||
<span>Full (24/24)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-space-lg pt-0">
|
||||
<button className="w-full py-2.5 rounded-lg bg-surface-container text-on-surface-variant font-headline-sm text-headline-sm cursor-not-allowed flex items-center justify-center gap-space-xs">
|
||||
<span className="material-symbols-outlined text-[18px]">hourglass_top</span>
|
||||
Join Waitlist (3)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Modal: Join Code Trigger Overlay (Interactive Script Attached) */}
|
||||
<div className="fixed inset-0 bg-surface-container-lowest/80 backdrop-blur-md z-50 hidden flex items-center justify-center p-space-md" id="join-modal">
|
||||
<div className="bg-surface-container-low max-w-md w-full rounded-xl p-space-xl shadow-2xl relative">
|
||||
<div className="flex items-center justify-between mb-space-lg">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="material-symbols-outlined text-primary-container">qr_code_scanner</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface">Join Pack Formation</h3>
|
||||
</div>
|
||||
<button className="text-on-surface-variant hover:text-on-surface" id="close-modal">
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-lg">
|
||||
Enter the 6-character encrypted ride code generated by your Lead Pace Setter or scan their cockpit radar QR display.
|
||||
</p>
|
||||
<div className="flex flex-col gap-space-md mb-space-lg">
|
||||
<input className="w-full bg-surface-container-lowest font-code-mono text-headline-sm text-center tracking-wide text-primary px-space-md py-3 rounded-lg outline-none placeholder:text-on-surface-variant" placeholder="Code: e.g. APEX84" type="text"/>
|
||||
<div className="flex items-center gap-space-md text-on-surface-variant font-code-mono text-code-mono justify-center">
|
||||
<span className="h-px bg-outline-variant flex-1"></span>
|
||||
<span>Or Scan Camera</span>
|
||||
<span className="h-px bg-outline-variant flex-1"></span>
|
||||
</div>
|
||||
<div className="bg-surface-container h-36 rounded-lg flex flex-col items-center justify-center gap-2 text-on-surface-variant hover:text-primary transition-colors cursor-pointer">
|
||||
<span className="material-symbols-outlined text-[36px]">photo_camera</span>
|
||||
<span className="font-code-mono text-code-mono">Activate Radar Lens</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full py-3 rounded-lg bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm">
|
||||
Connect Squad Telemetry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Public & Regional Group Rides Directory */}
|
||||
<GroupRidesDirectory rides={rides} onRequestSlot={handleRequestSlot} />
|
||||
|
||||
{/* Modals */}
|
||||
<JoinRideModal
|
||||
isOpen={isJoinModalOpen}
|
||||
onClose={() => setIsJoinModalOpen(false)}
|
||||
onJoinSuccess={handleJoinSuccess}
|
||||
/>
|
||||
|
||||
<CreateRideModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreateRide={handleCreateRide}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GroupRideSummary } from './types';
|
||||
|
||||
interface CreateRideModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateRide: (ride: GroupRideSummary) => void;
|
||||
}
|
||||
|
||||
export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [region, setRegion] = useState('');
|
||||
const [scheduledDate, setScheduledDate] = useState('Saturday');
|
||||
const [scheduledTime, setScheduledTime] = useState('07:00 AM PST');
|
||||
const [paceClassification, setPaceClassification] = useState<GroupRideSummary['paceClassification']>('Brisk Pace');
|
||||
const [formationProfile, setFormationProfile] = useState('Staggered Dual Column [B]');
|
||||
const [distance, setDistance] = useState('50.0 Miles');
|
||||
const [meetingPoint, setMeetingPoint] = useState('');
|
||||
const [participantLimit, setParticipantLimit] = useState(12);
|
||||
const [description, setDescription] = useState('');
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTitle('');
|
||||
setRegion('');
|
||||
setScheduledDate('Saturday');
|
||||
setScheduledTime('07:00 AM PST');
|
||||
setPaceClassification('Brisk Pace');
|
||||
setFormationProfile('Staggered Dual Column [B]');
|
||||
setDistance('50.0 Miles');
|
||||
setMeetingPoint('');
|
||||
setParticipantLimit(12);
|
||||
setDescription('');
|
||||
setErrorMsg(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle ESC key press
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
setErrorMsg('Please enter a ride title.');
|
||||
return;
|
||||
}
|
||||
if (!region.trim()) {
|
||||
setErrorMsg('Please enter a location or region.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newRide: GroupRideSummary = {
|
||||
id: `ride-${Date.now()}`,
|
||||
title: title.trim(),
|
||||
description: description.trim() || 'Custom squadron pack run with MotorSignal live telemetry.',
|
||||
region: region.trim(),
|
||||
scheduledDate,
|
||||
scheduledTime,
|
||||
paceClassification,
|
||||
formationProfile,
|
||||
joinPolicy: 'requestToJoin',
|
||||
leadHandle: 'You (Organizer)',
|
||||
leadAvatarText: 'YOU',
|
||||
participantCount: 1,
|
||||
participantLimit: Number(participantLimit) || 12,
|
||||
distance: distance.trim() || '45.0 Miles',
|
||||
meetingPoint: meetingPoint.trim() || 'Designated Rally Staging Point',
|
||||
userStatus: 'joined',
|
||||
};
|
||||
|
||||
onCreateRide(newRide);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-ride-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-space-md bg-surface-container-lowest/80 backdrop-blur-md overflow-y-auto"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-container-low max-w-lg w-full rounded-2xl p-space-xl shadow-2xl relative border border-outline-variant/30 flex flex-col my-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-space-md">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="material-symbols-outlined text-primary-container text-[24px]">
|
||||
add_circle
|
||||
</span>
|
||||
<h3 id="create-ride-title" className="font-headline-md text-headline-md text-on-surface">
|
||||
Create Group Ride Run
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-on-surface-variant hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-md">
|
||||
Organize a new squadron run with real-time radar tracking, safety spacing buffers, and synchronized telemetry check-ins.
|
||||
</p>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="mb-space-md p-space-sm rounded-lg bg-error-container text-on-error-container font-code-mono text-code-mono flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-space-md">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="ride-title-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Ride Title *
|
||||
</label>
|
||||
<input
|
||||
id="ride-title-input"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Sierra Foothills Canyon Run"
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Region & Distance Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
||||
<div>
|
||||
<label htmlFor="ride-region-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Region / City *
|
||||
</label>
|
||||
<input
|
||||
id="ride-region-input"
|
||||
type="text"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder="e.g. Sacramento, CA"
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ride-distance-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Route Distance
|
||||
</label>
|
||||
<input
|
||||
id="ride-distance-input"
|
||||
type="text"
|
||||
value={distance}
|
||||
onChange={(e) => setDistance(e.target.value)}
|
||||
placeholder="e.g. 58.4 Miles"
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date & Time Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
||||
<div>
|
||||
<label htmlFor="ride-date-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Scheduled Day
|
||||
</label>
|
||||
<input
|
||||
id="ride-date-input"
|
||||
type="text"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
placeholder="e.g. Saturday"
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ride-time-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
id="ride-time-input"
|
||||
type="text"
|
||||
value={scheduledTime}
|
||||
onChange={(e) => setScheduledTime(e.target.value)}
|
||||
placeholder="e.g. 06:45 AM PST"
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pace & Formation */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
||||
<div>
|
||||
<label htmlFor="ride-pace-select" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Pace Classification
|
||||
</label>
|
||||
<select
|
||||
id="ride-pace-select"
|
||||
value={paceClassification}
|
||||
onChange={(e) => setPaceClassification(e.target.value as any)}
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
>
|
||||
<option value="Track Pace">Track Pace</option>
|
||||
<option value="Brisk Pace">Brisk Pace</option>
|
||||
<option value="Endurance / Chill">Endurance / Chill</option>
|
||||
<option value="Weekend Tours">Weekend Tours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ride-limit-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Rider Limit
|
||||
</label>
|
||||
<input
|
||||
id="ride-limit-input"
|
||||
type="number"
|
||||
min={2}
|
||||
max={50}
|
||||
value={participantLimit}
|
||||
onChange={(e) => setParticipantLimit(Number(e.target.value))}
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meeting Point */}
|
||||
<div>
|
||||
<label htmlFor="ride-meeting-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Meeting Point / Staging Location
|
||||
</label>
|
||||
<input
|
||||
id="ride-meeting-input"
|
||||
type="text"
|
||||
value={meetingPoint}
|
||||
onChange={(e) => setMeetingPoint(e.target.value)}
|
||||
placeholder="e.g. Chevron Staging Lot at Highway 49"
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="ride-desc-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Description & Safety Briefing
|
||||
</label>
|
||||
<textarea
|
||||
id="ride-desc-input"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Formation rules, fuel stop locations, radio channels..."
|
||||
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-space-sm pt-space-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-space-lg py-2.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-headline-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-space-xl py-2.5 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all"
|
||||
>
|
||||
Publish Group Ride
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { GroupRideSummary, RadarRiderTelemetry } from './types';
|
||||
|
||||
export const INITIAL_RADAR_RIDERS: RadarRiderTelemetry[] = [
|
||||
{
|
||||
id: 'rider-1',
|
||||
callsign: 'ApexLead',
|
||||
role: 'Pace Setter',
|
||||
roleBadge: 'Lead',
|
||||
speedMph: 68,
|
||||
leanAngleDeg: 42,
|
||||
throttlePct: 78,
|
||||
brakePct: 0,
|
||||
gapMeters: 0,
|
||||
gapDeltaSec: 0,
|
||||
color: '#00F2FE', // cyan
|
||||
status: 'active',
|
||||
coordinates: { x: 84, y: 18 },
|
||||
},
|
||||
{
|
||||
id: 'rider-2',
|
||||
callsign: 'Ghost99',
|
||||
role: 'Wingman L',
|
||||
roleBadge: 'Wing L',
|
||||
speedMph: 67,
|
||||
leanAngleDeg: 40,
|
||||
throttlePct: 74,
|
||||
brakePct: 0,
|
||||
gapMeters: 45,
|
||||
gapDeltaSec: 0.4,
|
||||
color: '#00F2FE',
|
||||
status: 'active',
|
||||
coordinates: { x: 71, y: 32 },
|
||||
},
|
||||
{
|
||||
id: 'rider-3',
|
||||
callsign: 'Sarah_RR',
|
||||
role: 'Wingman R',
|
||||
roleBadge: 'Wing R',
|
||||
speedMph: 65,
|
||||
leanAngleDeg: 36,
|
||||
throttlePct: 62,
|
||||
brakePct: 0,
|
||||
gapMeters: 110,
|
||||
gapDeltaSec: 0.9,
|
||||
color: '#FF6B6B',
|
||||
status: 'warning',
|
||||
alertText: 'Rear tire 28 PSI (low)',
|
||||
coordinates: { x: 56, y: 34 },
|
||||
},
|
||||
{
|
||||
id: 'rider-4',
|
||||
callsign: 'Dan_KTM',
|
||||
role: 'Mid Pack',
|
||||
roleBadge: 'Mid',
|
||||
speedMph: 63,
|
||||
leanAngleDeg: 28,
|
||||
throttlePct: 58,
|
||||
brakePct: 0,
|
||||
gapMeters: 180,
|
||||
gapDeltaSec: 1.4,
|
||||
color: '#00F2FE',
|
||||
status: 'active',
|
||||
coordinates: { x: 36, y: 60 },
|
||||
},
|
||||
{
|
||||
id: 'rider-5',
|
||||
callsign: 'Alex_Ducati',
|
||||
role: 'Mid Pack',
|
||||
roleBadge: 'Decel Event',
|
||||
speedMph: 48,
|
||||
leanAngleDeg: 14,
|
||||
throttlePct: 0,
|
||||
brakePct: 85,
|
||||
gapMeters: 320,
|
||||
gapDeltaSec: 2.5,
|
||||
color: '#FF4444',
|
||||
status: 'alert',
|
||||
alertText: 'Hard Braking (-1.2G) ABS Engaged',
|
||||
coordinates: { x: 26, y: 78 },
|
||||
},
|
||||
{
|
||||
id: 'rider-6',
|
||||
callsign: 'Sweep_Marshal',
|
||||
role: 'Rear Guard',
|
||||
roleBadge: 'Sweep',
|
||||
speedMph: 59,
|
||||
leanAngleDeg: 22,
|
||||
throttlePct: 52,
|
||||
brakePct: 0,
|
||||
gapMeters: 450,
|
||||
gapDeltaSec: 3.8,
|
||||
color: '#39FF14', // neon green
|
||||
status: 'sweeper',
|
||||
coordinates: { x: 12, y: 88 },
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
||||
{
|
||||
id: 'ride-bay-dawn-patrol',
|
||||
title: 'Bay Area Dawn Patrol',
|
||||
description: 'Fast sweepers from Woodside down to San Gregorio beach before morning traffic. Strict staggered formation enforced.',
|
||||
region: 'San Francisco, CA',
|
||||
scheduledDate: 'Saturday',
|
||||
scheduledTime: '06:30 AM PST',
|
||||
paceClassification: 'Track Pace',
|
||||
formationProfile: 'Staggered Dual Column [B]',
|
||||
joinPolicy: 'requestToJoin',
|
||||
leadHandle: 'ApexLead',
|
||||
leadAvatarText: 'AL',
|
||||
participantCount: 14,
|
||||
participantLimit: 16,
|
||||
distance: '82.4 Miles',
|
||||
meetingPoint: "Alice's Restaurant, Skyline Blvd",
|
||||
userStatus: 'none',
|
||||
},
|
||||
{
|
||||
id: 'ride-malibu-canyons',
|
||||
title: 'Malibu Canyons Fast Pace',
|
||||
description: 'Tuna Canyon descent to Latigo Canyon technical curves. Regroup checkpoint planned at Rock Store overlook.',
|
||||
region: 'Malibu, CA',
|
||||
scheduledDate: 'Sunday',
|
||||
scheduledTime: '07:15 AM PST',
|
||||
paceClassification: 'Brisk Pace',
|
||||
formationProfile: 'Staggered Paceline',
|
||||
joinPolicy: 'requestToJoin',
|
||||
leadHandle: 'Dan_KTM',
|
||||
leadAvatarText: 'DK',
|
||||
participantCount: 9,
|
||||
participantLimit: 12,
|
||||
distance: '64.0 Miles',
|
||||
meetingPoint: 'Rock Store Overlook',
|
||||
userStatus: 'none',
|
||||
},
|
||||
{
|
||||
id: 'ride-tail-of-dragon',
|
||||
title: 'Tail of the Dragon Run',
|
||||
description: '11 miles, 318 technical curves. Telemetry debrief sessions every evening with custom apex analysis and gear logs.',
|
||||
region: 'Robbinsville, NC',
|
||||
scheduledDate: 'Fri-Sun',
|
||||
scheduledTime: 'Oct 12-14',
|
||||
paceClassification: 'Endurance / Chill',
|
||||
formationProfile: 'Dynamic Paceline',
|
||||
joinPolicy: 'open',
|
||||
leadHandle: 'Sweep_Marshal',
|
||||
leadAvatarText: 'SM',
|
||||
participantCount: 24,
|
||||
participantLimit: 24,
|
||||
distance: '318 Curves',
|
||||
meetingPoint: 'Deal’s Gap Motorcycle Resort',
|
||||
userStatus: 'none',
|
||||
},
|
||||
{
|
||||
id: 'ride-angeles-crest',
|
||||
title: 'Angeles Crest Sunrise Ridge',
|
||||
description: 'Sweeping high-altitude pavement through the San Gabriel Mountains. Moderate pace with designated cooldown turnouts.',
|
||||
region: 'La Cañada Flintridge, CA',
|
||||
scheduledDate: 'Saturday',
|
||||
scheduledTime: '06:00 AM PST',
|
||||
paceClassification: 'Brisk Pace',
|
||||
formationProfile: 'Staggered Dual Column',
|
||||
joinPolicy: 'open',
|
||||
leadHandle: 'Ghost99',
|
||||
leadAvatarText: 'G9',
|
||||
participantCount: 7,
|
||||
participantLimit: 10,
|
||||
distance: '52.8 Miles',
|
||||
meetingPoint: 'Shell Station at Foothill Blvd',
|
||||
userStatus: 'none',
|
||||
},
|
||||
{
|
||||
id: 'ride-lolo-pass',
|
||||
title: 'Lolo Pass Transcontinental Tour',
|
||||
description: 'Scenic sweepers following the Lochsa River through Idaho into Montana. Open touring formation with radio check-ins.',
|
||||
region: 'Missoula, MT',
|
||||
scheduledDate: 'Weekend',
|
||||
scheduledTime: 'Aug 18-20',
|
||||
paceClassification: 'Weekend Tours',
|
||||
formationProfile: 'Open Touring Formation',
|
||||
joinPolicy: 'open',
|
||||
leadHandle: 'Sarah_RR',
|
||||
leadAvatarText: 'SR',
|
||||
participantCount: 6,
|
||||
participantLimit: 15,
|
||||
distance: '134.5 Miles',
|
||||
meetingPoint: 'Lolo Hot Springs Rest Area',
|
||||
userStatus: 'none',
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface JoinRideModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onJoinSuccess: (code: string, rideTitle: string) => void;
|
||||
}
|
||||
|
||||
export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalProps) {
|
||||
const [code, setCode] = useState('');
|
||||
const [isCameraActive, setIsCameraActive] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [successInfo, setSuccessInfo] = useState<{ title: string; lead: string } | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when modal is opened/closed
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCode('');
|
||||
setErrorMsg(null);
|
||||
setSuccessInfo(null);
|
||||
setIsCameraActive(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle ESC key press
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '');
|
||||
setCode(raw);
|
||||
if (errorMsg) setErrorMsg(null);
|
||||
};
|
||||
|
||||
const processCode = (rideCode: string) => {
|
||||
const cleanCode = rideCode.trim().toUpperCase();
|
||||
if (!cleanCode || cleanCode.length < 4) {
|
||||
setErrorMsg('Please enter a valid ride code (at least 4 characters).');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg(null);
|
||||
|
||||
// Simulated lookup
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false);
|
||||
let resolvedTitle = 'Squad Formation Ride';
|
||||
let resolvedLead = 'ApexLead';
|
||||
|
||||
if (cleanCode.includes('APEX') || cleanCode === 'CA-84' || cleanCode === 'APEX84') {
|
||||
resolvedTitle = 'Bay Area Dawn Patrol';
|
||||
resolvedLead = 'ApexLead';
|
||||
} else if (cleanCode.includes('MALIBU') || cleanCode === 'MALIBU7') {
|
||||
resolvedTitle = 'Malibu Canyons Fast Pace';
|
||||
resolvedLead = 'Dan_KTM';
|
||||
} else if (cleanCode.includes('DRAGON') || cleanCode === 'DRAGON318') {
|
||||
resolvedTitle = 'Tail of the Dragon Run';
|
||||
resolvedLead = 'Sweep_Marshal';
|
||||
} else {
|
||||
resolvedTitle = `Squad Run #${cleanCode}`;
|
||||
resolvedLead = 'Pack Marshal';
|
||||
}
|
||||
|
||||
setSuccessInfo({ title: resolvedTitle, lead: resolvedLead });
|
||||
onJoinSuccess(cleanCode, resolvedTitle);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
processCode(code);
|
||||
};
|
||||
|
||||
const handleSimulateScan = () => {
|
||||
setCode('APEX84');
|
||||
processCode('APEX84');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="join-pack-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-space-md bg-surface-container-lowest/80 backdrop-blur-md"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-container-low max-w-md w-full rounded-2xl p-space-xl shadow-2xl relative border border-outline-variant/30 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-space-md">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="material-symbols-outlined text-primary-container text-[24px]">
|
||||
qr_code_scanner
|
||||
</span>
|
||||
<h3 id="join-pack-title" className="font-headline-md text-headline-md text-on-surface">
|
||||
Join Pack Formation
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-on-surface-variant hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Success State */}
|
||||
{successInfo ? (
|
||||
<div className="flex flex-col items-center text-center py-4">
|
||||
<div className="w-14 h-14 rounded-full bg-secondary-fixed/20 text-secondary-fixed flex items-center justify-center mb-3">
|
||||
<span className="material-symbols-outlined text-[32px]">check_circle</span>
|
||||
</div>
|
||||
<span className="font-label-caps text-label-caps text-secondary-fixed mb-1">
|
||||
Telemetry Linked
|
||||
</span>
|
||||
<h4 className="font-headline-md text-headline-md text-on-surface mb-1">
|
||||
{successInfo.title}
|
||||
</h4>
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant mb-6">
|
||||
Lead Marshal: <span className="text-on-surface font-bold">{successInfo.lead}</span>.
|
||||
Your telemetry stream is now synchronized with this pack.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all"
|
||||
>
|
||||
Open Pack Radar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Input / Scanner Flow */
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-md">
|
||||
Enter the encrypted 6-character ride code generated by your Lead Pace Setter or scan their cockpit radar QR display.
|
||||
</p>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMsg && (
|
||||
<div className="mb-space-md p-space-sm rounded-lg bg-error-container text-on-error-container font-code-mono text-code-mono flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code Input */}
|
||||
<div className="flex flex-col gap-space-md mb-space-lg">
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label="Ride Code"
|
||||
type="text"
|
||||
maxLength={10}
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
placeholder="e.g. APEX84"
|
||||
className="w-full bg-surface-container-lowest font-code-mono text-headline-md text-center tracking-widest text-primary px-space-md py-3 rounded-xl outline-none border border-outline-variant/30 focus:border-primary-container focus:ring-1 focus:ring-primary-container placeholder:text-on-surface-variant/40"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-space-md text-on-surface-variant font-code-mono text-code-mono justify-center text-xs">
|
||||
<span className="h-px bg-outline-variant flex-1"></span>
|
||||
<span>Or Scan Optical QR</span>
|
||||
<span className="h-px bg-outline-variant flex-1"></span>
|
||||
</div>
|
||||
|
||||
{/* Optical Lens / Camera Simulation */}
|
||||
{isCameraActive ? (
|
||||
<div
|
||||
className="relative bg-black rounded-xl h-44 overflow-hidden border border-primary-container flex flex-col items-center justify-center p-3"
|
||||
data-testid="radar-lens-viewfinder"
|
||||
>
|
||||
{/* Viewfinder crosshairs */}
|
||||
<div className="absolute inset-4 border border-secondary-fixed/40 rounded pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-secondary-fixed"></div>
|
||||
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-secondary-fixed"></div>
|
||||
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-secondary-fixed"></div>
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-secondary-fixed"></div>
|
||||
{/* Animated scanline */}
|
||||
<div className="w-full h-0.5 bg-gradient-to-r from-transparent via-primary-container to-transparent animate-pulse mt-12"></div>
|
||||
</div>
|
||||
|
||||
<span className="font-code-mono text-xs text-secondary-fixed mb-2 relative z-10">
|
||||
Radar Optical Lens Active
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSimulateScan}
|
||||
className="relative z-10 px-3 py-1.5 rounded-lg bg-primary-container text-on-primary-container font-code-mono text-xs hover:bg-primary-fixed transition-colors"
|
||||
>
|
||||
Simulate QR Scan (APEX84)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCameraActive(false)}
|
||||
className="mt-2 text-on-surface-variant hover:text-on-surface text-xs font-code-mono underline"
|
||||
>
|
||||
Switch back to manual entry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setIsCameraActive(true)}
|
||||
className="bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 h-32 rounded-xl flex flex-col items-center justify-center gap-2 text-on-surface-variant hover:text-primary transition-all cursor-pointer group"
|
||||
data-testid="activate-radar-lens-button"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[32px] group-hover:scale-110 transition-transform text-primary-fixed-dim">
|
||||
photo_camera
|
||||
</span>
|
||||
<span className="font-code-mono text-code-mono">Activate Radar Optical Lens</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm hover:shadow-[0_0_20px_rgba(0,242,254,0.3)] transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[20px] animate-spin">refresh</span>
|
||||
Linking Telemetry...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[20px]">sync_alt</span>
|
||||
Connect Squad Telemetry
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { RadarMapMode, RadarRiderTelemetry } from './types';
|
||||
import { INITIAL_RADAR_RIDERS } from './fixtures';
|
||||
|
||||
interface LivePackRadarProps {
|
||||
initialMode?: RadarMapMode;
|
||||
}
|
||||
|
||||
export function LivePackRadar({ initialMode = 'tactical' }: LivePackRadarProps) {
|
||||
const [mapMode, setMapMode] = useState<RadarMapMode>(initialMode);
|
||||
const [selectedRiderId, setSelectedRiderId] = useState<string | null>('rider-1');
|
||||
const [simulationTick, setSimulationTick] = useState(0);
|
||||
|
||||
// Active riders list with slight dynamic animation based on simulationTick
|
||||
const riders: RadarRiderTelemetry[] = INITIAL_RADAR_RIDERS.map((r, idx) => {
|
||||
// Subtle realistic wobble for demo interactivity
|
||||
const speedWobble = simulationTick > 0 ? ((simulationTick + idx * 3) % 5) - 2 : 0;
|
||||
return {
|
||||
...r,
|
||||
speedMph: Math.max(0, r.speedMph + speedWobble),
|
||||
};
|
||||
});
|
||||
|
||||
const selectedRider = riders.find((r) => r.id === selectedRiderId) || riders[0];
|
||||
|
||||
const handlePulseSimulation = () => {
|
||||
setSimulationTick((prev) => prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface-container-lowest rounded-xl shadow-2xl overflow-hidden border border-outline-variant/30">
|
||||
{/* Tactical Header Strip */}
|
||||
<div className="bg-surface-container-low px-space-lg py-space-sm flex flex-wrap items-center justify-between gap-space-md border-b border-outline-variant/20">
|
||||
<div className="flex items-center gap-space-md">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-secondary-fixed animate-pulse"></span>
|
||||
<span className="font-label-caps text-label-caps text-on-surface tracking-wide">
|
||||
Swarm Recon: Skyline Blvd > Coast Hwy
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant bg-surface-container px-2 py-0.5 rounded border border-outline-variant/20">
|
||||
Session #CA-84-SW
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-space-lg font-code-mono text-code-mono text-on-surface-variant">
|
||||
<span className="hidden lg:flex items-center gap-1.5 text-secondary-fixed">
|
||||
<span className="material-symbols-outlined text-[18px]">cell_tower</span>
|
||||
Mesh Active
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-secondary-fixed">●</span> {riders.length} Synchronized
|
||||
</span>
|
||||
<span className="hidden sm:inline text-on-surface-variant">Gap Delta: ±0.4s</span>
|
||||
<button
|
||||
onClick={handlePulseSimulation}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-surface-container hover:bg-surface-container-high text-primary font-code-mono text-code-mono transition-colors"
|
||||
title="Trigger Telemetry Ping"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
Ping Swarm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cockpit Layout: Map & Radar Feed */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0">
|
||||
{/* Tactical Canvas & Radar Screen */}
|
||||
<div
|
||||
className={`lg:col-span-8 relative min-h-[560px] flex flex-col justify-between p-space-md overflow-hidden transition-all duration-300 ${
|
||||
mapMode === 'tactical'
|
||||
? 'bg-[#0B1319]'
|
||||
: mapMode === 'satellite'
|
||||
? 'bg-[#071015]'
|
||||
: 'bg-[#0E1520]'
|
||||
}`}
|
||||
data-testid="radar-canvas-container"
|
||||
>
|
||||
{/* Tactical Background Overlay */}
|
||||
{mapMode === 'tactical' && (
|
||||
<>
|
||||
{/* Tactical Coordinate Grids (Inline SVG Overlay) */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="tactical-grid-pattern" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="rgba(0, 242, 254, 0.15)" strokeWidth="0.5" />
|
||||
<circle cx="0" cy="0" r="1.5" fill="rgba(0, 242, 254, 0.4)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#tactical-grid-pattern)" />
|
||||
{/* Tactical Route Trace */}
|
||||
<path
|
||||
d="M 80 480 C 180 410, 240 430, 340 330 S 480 280, 560 190 S 700 170, 820 100"
|
||||
fill="none"
|
||||
stroke="#00F2FE"
|
||||
strokeDasharray="8 6"
|
||||
strokeWidth="3.5"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
{/* Safe Spacing Buffers */}
|
||||
<circle cx="820" cy="100" r="32" fill="none" stroke="rgba(57, 255, 20, 0.3)" strokeWidth="1.5" />
|
||||
<circle cx="700" cy="170" r="28" fill="none" stroke="rgba(57, 255, 20, 0.3)" strokeWidth="1.5" />
|
||||
<circle cx="560" cy="190" r="34" fill="none" stroke="rgba(255, 107, 107, 0.4)" strokeWidth="2" strokeDasharray="4 2" />
|
||||
<circle cx="340" cy="330" r="26" fill="none" stroke="rgba(57, 255, 20, 0.3)" strokeWidth="1.5" />
|
||||
<circle cx="240" cy="430" r="36" fill="none" stroke="rgba(255, 68, 68, 0.6)" strokeDasharray="4 2" strokeWidth="2.5" />
|
||||
<circle cx="80" cy="480" r="30" fill="none" stroke="rgba(0, 242, 254, 0.4)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Satellite Mode Backdrop */}
|
||||
{mapMode === 'satellite' && (
|
||||
<div className="absolute inset-0 pointer-events-none" data-testid="satellite-mode-view">
|
||||
{/* Satellite Dark Topography Simulation */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#0A1620]/90 via-[#071118]/80 to-[#040A0F] mix-blend-multiply"></div>
|
||||
<svg className="absolute inset-0 w-full h-full opacity-30" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Topographic Contour lines */}
|
||||
<path d="M 0 100 Q 250 80 400 180 T 800 120 T 1200 220" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
||||
<path d="M 0 200 Q 200 150 450 260 T 900 200 T 1200 320" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
||||
<path d="M 0 320 Q 300 280 500 380 T 950 320 T 1200 440" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
||||
<path d="M 0 440 Q 350 400 600 480 T 1000 420 T 1200 540" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
||||
{/* Satellite Vector Track */}
|
||||
<path
|
||||
d="M 80 480 C 180 410, 240 430, 340 330 S 480 280, 560 190 S 700 170, 820 100"
|
||||
fill="none"
|
||||
stroke="#39FF14"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-surface-container/90 border border-outline-variant/30 text-on-surface font-code-mono text-label-caps text-center">
|
||||
High-Resolution Orbit Imagery • Multi-Band GNSS Overlaid
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Elevation 3D Mode Backdrop */}
|
||||
{mapMode === 'elevation' && (
|
||||
<div className="absolute inset-0 pointer-events-none" data-testid="elevation-mode-view">
|
||||
<svg className="absolute inset-0 w-full h-full opacity-40" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Elevation isometric grid lines */}
|
||||
<defs>
|
||||
<linearGradient id="elevationGrad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#39FF14" stopOpacity="0.8" />
|
||||
<stop offset="50%" stopColor="#00F2FE" stopOpacity="0.8" />
|
||||
<stop offset="100%" stopColor="#FF9F1C" stopOpacity="0.9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Mountain elevation profile ribbon */}
|
||||
<polygon
|
||||
points="80,500 180,430 340,350 560,210 700,180 820,110 820,530 80,530"
|
||||
fill="url(#elevationGrad)"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<path
|
||||
d="M 80 500 L 180 430 L 340 350 L 560 210 L 700 180 L 820 110"
|
||||
fill="none"
|
||||
stroke="url(#elevationGrad)"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</svg>
|
||||
{/* Elevation Altitude Badges */}
|
||||
<div className="absolute bottom-28 left-16 bg-surface/90 px-2 py-1 rounded text-primary font-code-mono text-label-caps border border-primary/30">
|
||||
Start: 1,480 ft
|
||||
</div>
|
||||
<div className="absolute top-28 right-24 bg-surface/90 px-2 py-1 rounded text-secondary-fixed font-code-mono text-label-caps border border-secondary-fixed/30">
|
||||
Summit: 2,210 ft (+730 ft gain)
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 bg-surface/90 px-2.5 py-1 rounded text-on-surface font-code-mono text-label-caps border border-outline-variant/30">
|
||||
Grade: 8.4% Apex Switchback
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map HUD Top Left: Sector Status */}
|
||||
<div className="relative z-10 flex items-start justify-between">
|
||||
<div className="bg-surface/90 backdrop-blur-md px-space-md py-2.5 rounded-xl shadow-lg border border-outline-variant/30 flex flex-col gap-0.5">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Formation Profile</span>
|
||||
<span className="font-headline-sm text-headline-sm text-primary font-bold">Staggered Dual Column [B]</span>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">shield</span> Paceline Lock: Optimal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Map HUD Top Right: Telemetry & Mesh Lock (GPS Frequency label removed as requested) */}
|
||||
<div className="bg-surface/90 backdrop-blur-md px-space-md py-2 rounded-xl shadow-lg border border-outline-variant/30 flex items-center gap-space-sm">
|
||||
<div className="text-right">
|
||||
<span className="font-label-caps text-label-caps text-secondary-fixed block font-bold">
|
||||
GNSS Multi-Band Mesh
|
||||
</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">18 Satellites Locked</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-secondary-fixed text-[24px]">satellite_alt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Rider Markers across the canvas */}
|
||||
<div className="relative z-10 my-auto h-72 w-full">
|
||||
{riders.map((rider) => {
|
||||
const isSelected = selectedRiderId === rider.id;
|
||||
return (
|
||||
<div
|
||||
key={rider.id}
|
||||
onClick={() => setSelectedRiderId(rider.id)}
|
||||
style={{
|
||||
left: `${rider.coordinates.x}%`,
|
||||
top: `${rider.coordinates.y}%`,
|
||||
}}
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer transition-all duration-200 group ${
|
||||
isSelected ? 'z-30 scale-110' : 'z-20 hover:scale-105'
|
||||
}`}
|
||||
data-testid={`radar-marker-${rider.callsign}`}
|
||||
>
|
||||
{/* Outer targeting reticle if selected */}
|
||||
{isSelected && (
|
||||
<div className="absolute -inset-2 rounded-full border-2 border-primary-container animate-ping pointer-events-none opacity-60"></div>
|
||||
)}
|
||||
|
||||
{/* Marker Badge */}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded shadow-xl backdrop-blur-md transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary-container text-on-primary-container ring-2 ring-primary-fixed shadow-[0_0_16px_rgba(0,242,254,0.6)]'
|
||||
: rider.status === 'alert'
|
||||
? 'bg-error-container/90 text-on-error-container border border-error'
|
||||
: rider.status === 'warning'
|
||||
? 'bg-surface-container-high/90 text-on-surface border border-error/50'
|
||||
: 'bg-surface-container-high/90 text-on-surface border border-outline-variant/30 hover:border-primary-container'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
rider.status === 'alert'
|
||||
? 'bg-error animate-bounce'
|
||||
: rider.status === 'warning'
|
||||
? 'bg-error animate-pulse'
|
||||
: isSelected
|
||||
? 'bg-on-primary-container'
|
||||
: 'bg-secondary-fixed animate-pulse'
|
||||
}`}
|
||||
></span>
|
||||
<span className="font-code-mono text-code-mono font-bold">{rider.callsign}</span>
|
||||
<span
|
||||
className={`font-label-caps text-label-caps font-semibold ${
|
||||
isSelected
|
||||
? 'text-on-primary-container'
|
||||
: rider.status === 'alert'
|
||||
? 'text-error'
|
||||
: 'text-secondary-fixed'
|
||||
}`}
|
||||
>
|
||||
{rider.speedMph} MPH
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Deceleration Warning Callout */}
|
||||
{rider.status === 'alert' && (
|
||||
<div className="absolute -bottom-7 left-1/2 -translate-x-1/2 whitespace-nowrap bg-error text-white font-code-mono text-[11px] px-2 py-0.5 rounded shadow-lg flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[13px]">warning</span>
|
||||
-1.2G Decel
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Map HUD Bottom Control Strip */}
|
||||
<div className="relative z-10 flex flex-wrap items-center justify-between gap-space-sm mt-auto pt-space-lg">
|
||||
{/* Radar View Mode Toggles */}
|
||||
<div className="flex items-center gap-space-xs bg-surface/90 backdrop-blur-md p-1.5 rounded-xl border border-outline-variant/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapMode('tactical')}
|
||||
className={`px-space-sm py-1 rounded-lg font-label-caps text-label-caps transition-all ${
|
||||
mapMode === 'tactical'
|
||||
? 'bg-primary-container text-on-primary-container font-bold shadow-md'
|
||||
: 'text-on-surface-variant hover:text-on-surface hover:bg-surface-container'
|
||||
}`}
|
||||
>
|
||||
Tactical
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapMode('satellite')}
|
||||
className={`px-space-sm py-1 rounded-lg font-label-caps text-label-caps transition-all ${
|
||||
mapMode === 'satellite'
|
||||
? 'bg-primary-container text-on-primary-container font-bold shadow-md'
|
||||
: 'text-on-surface-variant hover:text-on-surface hover:bg-surface-container'
|
||||
}`}
|
||||
>
|
||||
Satellite
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapMode('elevation')}
|
||||
className={`px-space-sm py-1 rounded-lg font-label-caps text-label-caps transition-all ${
|
||||
mapMode === 'elevation'
|
||||
? 'bg-primary-container text-on-primary-container font-bold shadow-md'
|
||||
: 'text-on-surface-variant hover:text-on-surface hover:bg-surface-container'
|
||||
}`}
|
||||
>
|
||||
Elevation 3D
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Regroup point readout */}
|
||||
<div className="bg-surface/90 backdrop-blur-md px-space-md py-1.5 rounded-xl flex items-center gap-space-sm border border-outline-variant/30">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Regroup Point</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-container font-bold">
|
||||
Alice's Restaurant (14.8 mi)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Swarm Nodes Telemetry Column */}
|
||||
<div className="lg:col-span-4 bg-surface-container p-space-md flex flex-col gap-space-sm overflow-y-auto max-h-[620px] border-t lg:border-t-0 lg:border-l border-outline-variant/20">
|
||||
<div className="flex items-center justify-between pb-space-xs mb-space-xs border-b border-outline-variant/20">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant tracking-wide">
|
||||
Pack Telemetry Stream
|
||||
</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim">
|
||||
{riders.length} Active Nodes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Active Rider Telemetry Cards */}
|
||||
{riders.map((rider) => {
|
||||
const isSelected = selectedRiderId === rider.id;
|
||||
return (
|
||||
<div
|
||||
key={rider.id}
|
||||
onClick={() => setSelectedRiderId(rider.id)}
|
||||
className={`p-space-sm rounded-xl cursor-pointer transition-all border ${
|
||||
isSelected
|
||||
? 'bg-surface-container-highest border-primary-container shadow-md ring-1 ring-primary-container/50'
|
||||
: 'bg-surface-container-high border-transparent hover:bg-surface-container-highest hover:border-outline-variant/30'
|
||||
}`}
|
||||
data-testid={`telemetry-card-${rider.callsign}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-space-xs">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
rider.status === 'alert'
|
||||
? 'bg-error animate-bounce'
|
||||
: rider.status === 'warning'
|
||||
? 'bg-error animate-pulse'
|
||||
: rider.status === 'sweeper'
|
||||
? 'bg-secondary-fixed'
|
||||
: 'bg-secondary-fixed'
|
||||
}`}
|
||||
></span>
|
||||
<span className="font-headline-sm text-headline-sm text-on-surface font-bold">
|
||||
{rider.callsign}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-secondary-fixed">
|
||||
{rider.role}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-code-mono text-code-mono ${
|
||||
rider.gapMeters === 0 ? 'text-primary-fixed font-bold' : 'text-secondary-fixed'
|
||||
}`}
|
||||
>
|
||||
{rider.gapMeters === 0 ? 'Lead' : `+${rider.gapMeters}m Gap`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Normal Telemetry Grid */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 font-code-mono text-code-mono text-on-surface-variant">
|
||||
<div>
|
||||
Speed: <span className="text-on-surface font-bold">{rider.speedMph} MPH</span>
|
||||
</div>
|
||||
<div>
|
||||
Lean: <span className="text-primary-container font-bold">{rider.leanAngleDeg}°</span>
|
||||
</div>
|
||||
<div>
|
||||
{rider.brakePct && rider.brakePct > 0 ? (
|
||||
<span className="text-error font-bold">Brake: {rider.brakePct}%</span>
|
||||
) : rider.throttlePct !== undefined ? (
|
||||
<span>Throttle: {rider.throttlePct}%</span>
|
||||
) : (
|
||||
<span>Delta: ±{rider.gapDeltaSec}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning / Alert Callout */}
|
||||
{rider.alertText && (
|
||||
<div className="mt-2 pt-1 border-t border-outline-variant/20 flex items-center gap-1 font-code-mono text-code-mono text-error">
|
||||
<span className="material-symbols-outlined text-[15px]">
|
||||
{rider.status === 'alert' ? 'warning' : 'tire_repair'}
|
||||
</span>
|
||||
<span className="truncate">{rider.alertText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Selected Rider Inspector HUD Drawer */}
|
||||
{selectedRider && (
|
||||
<div
|
||||
className="mt-auto p-space-sm rounded-xl bg-surface-container-lowest border border-primary-container/40"
|
||||
data-testid="selected-node-inspector"
|
||||
>
|
||||
<div className="flex items-center justify-between text-label-caps font-label-caps text-primary-container mb-1">
|
||||
<span>Selected Node Inspector</span>
|
||||
<span className="font-code-mono">{selectedRider.callsign}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-code-mono font-code-mono text-on-surface-variant text-[12px]">
|
||||
<span>Role: {selectedRider.roleBadge}</span>
|
||||
<span>Gap Delta: ±{selectedRider.gapDeltaSec}s</span>
|
||||
<span className="text-secondary-fixed">Live Sync: 20Hz</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formation Safety Metrics Panel Strip */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 bg-surface-container-highest p-space-lg gap-space-lg border-t border-outline-variant/20">
|
||||
{/* Metric 1: Spacing Integrity */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Spacing Integrity</span>
|
||||
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-secondary-fixed">94%</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">Safety Index</span>
|
||||
</div>
|
||||
<div className="w-full bg-surface-container h-1.5 rounded-full overflow-hidden mt-1">
|
||||
<div className="bg-secondary-fixed h-full rounded-full w-[94%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric 2: Average Pack Speed */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Average Pack Velocity</span>
|
||||
<span className="material-symbols-outlined text-primary-container text-[18px]">speed</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-on-surface">64.2</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim">MPH</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">
|
||||
Delta Drift: -1.8 MPH (tightening)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metric 3: Atmospheric & Track Conditions */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Surface & Climate</span>
|
||||
<span className="material-symbols-outlined text-on-surface-variant text-[18px]">thermostat</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-on-surface">21°C</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface-variant">Dry Asphalt</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-secondary-fixed">0% Precip • Grip Optimal</span>
|
||||
</div>
|
||||
|
||||
{/* Metric 4: Next Regroup Waypoint */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label-caps text-label-caps text-on-surface-variant">Regroup Point</span>
|
||||
<span className="material-symbols-outlined text-primary-container text-[18px]">pin_drop</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-space-xs">
|
||||
<span className="font-telemetry-metric-md text-telemetry-metric-md text-primary-container">14.8</span>
|
||||
<span className="font-code-mono text-code-mono text-primary-fixed-dim">Miles</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Alice's Restaurant Checkpoint</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
export type RadarMapMode = 'tactical' | 'satellite' | 'elevation';
|
||||
|
||||
export type RidePaceFilter = 'all' | 'brisk' | 'track' | 'tour';
|
||||
|
||||
export type RideSortOption = 'time' | 'distance' | 'riders';
|
||||
|
||||
export interface RadarRiderTelemetry {
|
||||
id: string;
|
||||
callsign: string;
|
||||
role: string;
|
||||
roleBadge: string;
|
||||
speedMph: number;
|
||||
leanAngleDeg: number;
|
||||
throttlePct?: number;
|
||||
brakePct?: number;
|
||||
gapMeters: number;
|
||||
gapDeltaSec: number;
|
||||
color: string;
|
||||
status: 'active' | 'warning' | 'alert' | 'sweeper';
|
||||
alertText?: string;
|
||||
coordinates: { x: number; y: number }; // percentage on radar canvas (0-100)
|
||||
}
|
||||
|
||||
export interface GroupRideSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
region: string;
|
||||
scheduledTime: string;
|
||||
scheduledDate: string;
|
||||
paceClassification: 'Track Pace' | 'Brisk Pace' | 'Endurance / Chill' | 'Weekend Tours';
|
||||
formationProfile: string;
|
||||
joinPolicy: 'open' | 'requestToJoin' | 'inviteOnly';
|
||||
leadHandle: string;
|
||||
leadAvatarText: string;
|
||||
participantCount: number;
|
||||
participantLimit: number;
|
||||
distance: string;
|
||||
meetingPoint: string;
|
||||
userStatus?: 'none' | 'requested' | 'joined' | 'waitlisted';
|
||||
}
|
||||
|
||||
export interface CreateRideFormData {
|
||||
title: string;
|
||||
region: string;
|
||||
description: string;
|
||||
paceClassification: GroupRideSummary['paceClassification'];
|
||||
formationProfile: string;
|
||||
joinPolicy: GroupRideSummary['joinPolicy'];
|
||||
scheduledDate: string;
|
||||
scheduledTime: string;
|
||||
distance: string;
|
||||
meetingPoint: string;
|
||||
participantLimit: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import GroupRidesPage from '../app/group-rides/page';
|
||||
import { LivePackRadar } from '../components/group-rides/live-pack-radar';
|
||||
import { JoinRideModal } from '../components/group-rides/join-ride-modal';
|
||||
import { CreateRideModal } from '../components/group-rides/create-ride-modal';
|
||||
import { GroupRidesDirectory } from '../components/group-rides/group-rides-directory';
|
||||
import { INITIAL_GROUP_RIDES } from '../components/group-rides/fixtures';
|
||||
|
||||
describe('Group Rides Interactive Page & Components', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 1. LivePackRadar Component Tests
|
||||
// ============================================================
|
||||
describe('LivePackRadar', () => {
|
||||
it('renders radar HUD, default tactical view, and all 6 rider telemetry cards', () => {
|
||||
render(<LivePackRadar initialMode="tactical" />);
|
||||
|
||||
// Verify header and HUD
|
||||
expect(screen.getByText(/Swarm Recon: Skyline Blvd > Coast Hwy/i)).toBeDefined();
|
||||
expect(screen.getByText(/Session #CA-84-SW/i)).toBeDefined();
|
||||
expect(screen.getByText(/6 Synchronized/i)).toBeDefined();
|
||||
|
||||
// Verify GPS Frequency is NOT present, but GNSS Multi-Band Mesh IS present
|
||||
expect(screen.queryByText(/GPS Frequency/i)).toBeNull();
|
||||
expect(screen.getByText(/GNSS Multi-Band Mesh/i)).toBeDefined();
|
||||
expect(screen.getByText(/18 Satellites Locked/i)).toBeDefined();
|
||||
|
||||
// Verify all riders in the stream
|
||||
expect(screen.getByTestId('telemetry-card-ApexLead')).toBeDefined();
|
||||
expect(screen.getByTestId('telemetry-card-Ghost99')).toBeDefined();
|
||||
expect(screen.getByTestId('telemetry-card-Sarah_RR')).toBeDefined();
|
||||
expect(screen.getByTestId('telemetry-card-Dan_KTM')).toBeDefined();
|
||||
expect(screen.getByTestId('telemetry-card-Alex_Ducati')).toBeDefined();
|
||||
expect(screen.getByTestId('telemetry-card-Sweep_Marshal')).toBeDefined();
|
||||
|
||||
// Verify alert text
|
||||
expect(screen.getByText(/Rear tire 28 PSI \(low\)/i)).toBeDefined();
|
||||
expect(screen.getByText(/Hard Braking \(-1.2G\) ABS Engaged/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('allows toggling between Tactical, Satellite, and Elevation 3D modes', () => {
|
||||
render(<LivePackRadar initialMode="tactical" />);
|
||||
|
||||
const tacticalBtn = screen.getByRole('button', { name: /^tactical$/i });
|
||||
const satelliteBtn = screen.getByRole('button', { name: /^satellite$/i });
|
||||
const elevationBtn = screen.getByRole('button', { name: /^elevation 3d$/i });
|
||||
|
||||
// Click Satellite
|
||||
fireEvent.click(satelliteBtn);
|
||||
expect(screen.getByTestId('satellite-mode-view')).toBeDefined();
|
||||
expect(screen.getByText(/High-Resolution Orbit Imagery/i)).toBeDefined();
|
||||
|
||||
// Click Elevation 3D
|
||||
fireEvent.click(elevationBtn);
|
||||
expect(screen.getByTestId('elevation-mode-view')).toBeDefined();
|
||||
expect(screen.getByText(/Summit: 2,210 ft/i)).toBeDefined();
|
||||
|
||||
// Return to Tactical
|
||||
fireEvent.click(tacticalBtn);
|
||||
expect(screen.queryByTestId('satellite-mode-view')).toBeNull();
|
||||
expect(screen.queryByTestId('elevation-mode-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows selecting riders from telemetry column or canvas markers', () => {
|
||||
render(<LivePackRadar initialMode="tactical" />);
|
||||
|
||||
// Default selected is ApexLead
|
||||
expect(screen.getByText(/Selected Node Inspector/i)).toBeDefined();
|
||||
|
||||
// Select Alex_Ducati via telemetry card
|
||||
const alexCard = screen.getByTestId('telemetry-card-Alex_Ducati');
|
||||
fireEvent.click(alexCard);
|
||||
|
||||
// Verify inspector reflects Alex_Ducati
|
||||
const inspector = screen.getByTestId('selected-node-inspector');
|
||||
expect(inspector.textContent).toContain('Alex_Ducati');
|
||||
expect(inspector.textContent).toContain('Decel Event');
|
||||
|
||||
// Select Ghost99 via canvas marker
|
||||
const ghostMarker = screen.getByTestId('radar-marker-Ghost99');
|
||||
fireEvent.click(ghostMarker);
|
||||
expect(inspector.textContent).toContain('Ghost99');
|
||||
});
|
||||
|
||||
it('animates/wobbles speed on ping swarm button click', () => {
|
||||
render(<LivePackRadar initialMode="tactical" />);
|
||||
const pingBtn = screen.getByRole('button', { name: /ping swarm/i });
|
||||
fireEvent.click(pingBtn);
|
||||
// Still renders active telemetry correctly
|
||||
expect(screen.getByText(/6 Synchronized/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2. JoinRideModal Component Tests
|
||||
// ============================================================
|
||||
describe('JoinRideModal', () => {
|
||||
it('handles open/close lifecycle and Escape key', () => {
|
||||
const onClose = vi.fn();
|
||||
const onJoinSuccess = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<JoinRideModal isOpen={false} onClose={onClose} onJoinSuccess={onJoinSuccess} />
|
||||
);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
rerender(<JoinRideModal isOpen={true} onClose={onClose} onJoinSuccess={onJoinSuccess} />);
|
||||
expect(screen.getByRole('dialog')).toBeDefined();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates short ride code and rejects submission with error', () => {
|
||||
const onClose = vi.fn();
|
||||
const onJoinSuccess = vi.fn();
|
||||
|
||||
render(<JoinRideModal isOpen={true} onClose={onClose} onJoinSuccess={onJoinSuccess} />);
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /connect squad telemetry/i });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
expect(screen.getByText(/Please enter a valid ride code/i)).toBeDefined();
|
||||
expect(onJoinSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('activates radar lens camera and simulates QR scan successfully', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onJoinSuccess = vi.fn();
|
||||
|
||||
render(<JoinRideModal isOpen={true} onClose={onClose} onJoinSuccess={onJoinSuccess} />);
|
||||
|
||||
// Click activate optical lens
|
||||
const lensTrigger = screen.getByTestId('activate-radar-lens-button');
|
||||
fireEvent.click(lensTrigger);
|
||||
|
||||
expect(screen.getByTestId('radar-lens-viewfinder')).toBeDefined();
|
||||
|
||||
// Click simulate QR scan
|
||||
const simulateScanBtn = screen.getByRole('button', { name: /simulate qr scan/i });
|
||||
fireEvent.click(simulateScanBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Telemetry Linked/i)).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Bay Area Dawn Patrol/i)).toBeDefined();
|
||||
expect(onJoinSuccess).toHaveBeenCalledWith('APEX84', 'Bay Area Dawn Patrol');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 3. CreateRideModal Component Tests
|
||||
// ============================================================
|
||||
describe('CreateRideModal', () => {
|
||||
it('validates required fields before submitting', () => {
|
||||
const onClose = vi.fn();
|
||||
const onCreateRide = vi.fn();
|
||||
|
||||
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /publish group ride/i });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
expect(screen.getByText(/Please enter a ride title/i)).toBeDefined();
|
||||
expect(onCreateRide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits valid ride and invokes onCreateRide callback', () => {
|
||||
const onClose = vi.fn();
|
||||
const onCreateRide = vi.fn();
|
||||
|
||||
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
|
||||
|
||||
const titleInput = screen.getByLabelText(/ride title/i);
|
||||
const regionInput = screen.getByLabelText(/region \/ city/i);
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: 'Skyline Sunrise Blast' } });
|
||||
fireEvent.change(regionInput, { target: { value: 'Palo Alto, CA' } });
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /publish group ride/i });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
expect(onCreateRide).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Skyline Sunrise Blast',
|
||||
region: 'Palo Alto, CA',
|
||||
userStatus: 'joined',
|
||||
})
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 4. GroupRidesDirectory Component Tests
|
||||
// ============================================================
|
||||
describe('GroupRidesDirectory', () => {
|
||||
it('renders initial rides with correct pace tabs and badges', () => {
|
||||
render(<GroupRidesDirectory rides={INITIAL_GROUP_RIDES} onRequestSlot={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/Bay Area Dawn Patrol/i)).toBeDefined();
|
||||
expect(screen.getByText(/Malibu Canyons Fast Pace/i)).toBeDefined();
|
||||
expect(screen.getByText(/Tail of the Dragon Run/i)).toBeDefined();
|
||||
|
||||
// Pace tabs
|
||||
expect(screen.getByRole('button', { name: /all runs/i })).toBeDefined();
|
||||
expect(screen.getByRole('button', { name: /brisk pace/i })).toBeDefined();
|
||||
expect(screen.getByRole('button', { name: /track \/ apex/i })).toBeDefined();
|
||||
expect(screen.getByRole('button', { name: /weekend tours/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters rides by pace tab', () => {
|
||||
render(<GroupRidesDirectory rides={INITIAL_GROUP_RIDES} onRequestSlot={vi.fn()} />);
|
||||
|
||||
// Click Track / Apex
|
||||
const trackBtn = screen.getByRole('button', { name: /track \/ apex/i });
|
||||
fireEvent.click(trackBtn);
|
||||
|
||||
expect(screen.getByText(/Bay Area Dawn Patrol/i)).toBeDefined();
|
||||
expect(screen.queryByText(/Malibu Canyons Fast Pace/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('filters rides by search text and shows empty state when no match', () => {
|
||||
render(<GroupRidesDirectory rides={INITIAL_GROUP_RIDES} onRequestSlot={vi.fn()} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search by region, canyon/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'Robbinsville' } });
|
||||
|
||||
expect(screen.getByText(/Tail of the Dragon Run/i)).toBeDefined();
|
||||
expect(screen.queryByText(/Bay Area Dawn Patrol/i)).toBeNull();
|
||||
|
||||
// Non-matching search
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistent-canyon-run' } });
|
||||
expect(screen.getByText(/No Matching Pack Runs/i)).toBeDefined();
|
||||
|
||||
// Reset button restores
|
||||
const resetBtn = screen.getByRole('button', { name: /reset filters/i });
|
||||
fireEvent.click(resetBtn);
|
||||
expect(screen.getByText(/Bay Area Dawn Patrol/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles requesting a slot', () => {
|
||||
const onRequestSlot = vi.fn();
|
||||
render(<GroupRidesDirectory rides={INITIAL_GROUP_RIDES} onRequestSlot={onRequestSlot} />);
|
||||
|
||||
const requestButtons = screen.getAllByRole('button', { name: /request pack slot/i });
|
||||
expect(requestButtons.length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(requestButtons[0]);
|
||||
expect(onRequestSlot).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 5. Full GroupRidesPage Integrated Flow
|
||||
// ============================================================
|
||||
describe('Full GroupRidesPage Integration', () => {
|
||||
it('coordinates create ride and join ride modals and alerts', async () => {
|
||||
render(<GroupRidesPage />);
|
||||
|
||||
// Hero check
|
||||
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
||||
expect(screen.getByText(/Never lose a wingman/i)).toBeDefined();
|
||||
|
||||
// Open Create Modal from hero button
|
||||
const createHeroBtn = screen.getByRole('button', { name: /create group ride/i });
|
||||
fireEvent.click(createHeroBtn);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /create group ride run/i })).toBeDefined();
|
||||
|
||||
// Fill and submit
|
||||
fireEvent.change(screen.getByLabelText(/ride title/i), {
|
||||
target: { value: 'Redwood Highway Sprint' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/region \/ city/i), {
|
||||
target: { value: 'Eureka, CA' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
|
||||
|
||||
// Directory should now show the new ride at the top
|
||||
expect(screen.getByRole('heading', { name: /Redwood Highway Sprint/i })).toBeDefined();
|
||||
expect(screen.getByText(/Eureka, CA/i)).toBeDefined();
|
||||
|
||||
// Open Join Modal from hero button
|
||||
const joinHeroBtn = screen.getByRole('button', { name: /join with ride code \/ qr/i });
|
||||
fireEvent.click(joinHeroBtn);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /join pack formation/i })).toBeDefined();
|
||||
|
||||
// Enter code and submit
|
||||
const codeInput = screen.getByLabelText(/ride code/i);
|
||||
fireEvent.change(codeInput, { target: { value: 'APEX84' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /connect squad telemetry/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Telemetry Linked/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue