feat(website): create separate showcase and management group rides pages
This commit is contained in:
parent
126f2c6c5f
commit
69fefd7b56
|
|
@ -0,0 +1,57 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { onAuthStateChanged, User } from 'firebase/auth';
|
||||||
|
import { firebaseAuth } from '../../../lib/firebase-client';
|
||||||
|
import { GroupRidesManagement } from '../../../components/group-rides/group-rides-management';
|
||||||
|
|
||||||
|
export default function GroupRidesManageRoute() {
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(firebaseAuth?.currentUser ?? null);
|
||||||
|
const [authChecked, setAuthChecked] = useState(Boolean(firebaseAuth?.currentUser));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
setAuthChecked(true);
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// If not logged in, render an access prompt directing them to sign in
|
||||||
|
if (!currentUser && authChecked) {
|
||||||
|
return (
|
||||||
|
<div className="flagship-page min-h-screen bg-surface text-on-surface flex flex-col justify-center items-center px-4 py-16">
|
||||||
|
<div className="max-w-md w-full bg-surface-container-low border border-outline-variant/30 rounded-3xl p-8 shadow-2xl text-center space-y-5">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-primary-container/20 text-primary-container mx-auto flex items-center justify-center">
|
||||||
|
<span className="material-symbols-outlined text-[30px]">lock</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-headline-lg text-xl font-bold text-on-surface">
|
||||||
|
Authentication Required
|
||||||
|
</h1>
|
||||||
|
<p className="font-body-sm text-xs text-on-surface-variant mt-2 leading-relaxed">
|
||||||
|
The Group Rides Management Console allows riders to schedule, coordinate, and modify live squad pack runs. Please sign in to your rider account to proceed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
|
<Link
|
||||||
|
href="/login?redirect=/group-rides/manage"
|
||||||
|
className="w-full py-3 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-xs font-bold shadow-lg hover:shadow-[0_0_20px_rgba(0,242,254,0.4)] transition"
|
||||||
|
>
|
||||||
|
Sign In to Access Management
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/group-rides/showcase"
|
||||||
|
className="w-full py-2.5 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-semibold text-on-surface transition"
|
||||||
|
>
|
||||||
|
Explore Feature Showcase
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <GroupRidesManagement currentUser={currentUser} />;
|
||||||
|
}
|
||||||
|
|
@ -1,303 +1,58 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { INITIAL_GROUP_RIDES } from '../../components/group-rides/fixtures';
|
import { onAuthStateChanged, User } from 'firebase/auth';
|
||||||
import { GroupRideSummary } from '../../components/group-rides/types';
|
import { firebaseAuth } from '../../lib/firebase-client';
|
||||||
import { LivePackRadar } from '../../components/group-rides/live-pack-radar';
|
import { GroupRidesShowcase } from '../../components/group-rides/group-rides-showcase';
|
||||||
import { GroupRidesDirectory } from '../../components/group-rides/group-rides-directory';
|
import { GroupRidesManagement } from '../../components/group-rides/group-rides-management';
|
||||||
import { JoinRideModal } from '../../components/group-rides/join-ride-modal';
|
|
||||||
import { CreateRideModal } from '../../components/group-rides/create-ride-modal';
|
|
||||||
|
|
||||||
export default function GroupRidesPage() {
|
export default function GroupRidesPage() {
|
||||||
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
|
const [currentUser, setCurrentUser] = useState<User | null>(firebaseAuth?.currentUser ?? null);
|
||||||
const [isJoinModalOpen, setIsJoinModalOpen] = useState(false);
|
const [viewOverride, setViewOverride] = useState<'showcase' | 'manage' | null>(null);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [activeAlert, setActiveAlert] = useState<{ message: string; type: 'success' | 'info' } | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const showAlert = (message: string, type: 'success' | 'info' = 'success') => {
|
useEffect(() => {
|
||||||
setActiveAlert({ message, type });
|
// Check URL parameters for explicit view overrides (?view=showcase or ?view=manage)
|
||||||
setTimeout(() => {
|
if (typeof window !== 'undefined') {
|
||||||
setActiveAlert(null);
|
const params = new URLSearchParams(window.location.search);
|
||||||
}, 4500);
|
const view = params.get('view');
|
||||||
};
|
if (view === 'showcase' || view === 'manage') {
|
||||||
|
setViewOverride(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleJoinSuccess = (code: string, rideTitle: string) => {
|
const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
|
||||||
showAlert(`Successfully synchronized telemetry with "${rideTitle}" (Code: ${code})!`, 'success');
|
setCurrentUser(user);
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleCreateRide = (newRide: GroupRideSummary) => {
|
return () => unsubscribe();
|
||||||
setRides((prev) => [newRide, ...prev]);
|
}, []);
|
||||||
showAlert(`Published group run "${newRide.title}"! Pack radar is now active.`, 'success');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestSlot = (rideId: string) => {
|
// If explicit showcase view requested
|
||||||
setRides((prev) =>
|
if (viewOverride === 'showcase') {
|
||||||
prev.map((r) => {
|
return (
|
||||||
if (r.id !== rideId) return r;
|
<GroupRidesShowcase
|
||||||
if (r.participantCount >= r.participantLimit) {
|
isLoggedIn={Boolean(currentUser)}
|
||||||
showAlert(`Added to waitlist for "${r.title}". You will be alerted if a slot opens.`, 'info');
|
userEmail={currentUser?.email}
|
||||||
return { ...r, userStatus: 'waitlisted' };
|
onGoToManagement={() => setViewOverride('manage')}
|
||||||
}
|
/>
|
||||||
showAlert(`Slot requested for "${r.title}". Telemetry sync confirmed with Lead Marshal!`, 'success');
|
|
||||||
return {
|
|
||||||
...r,
|
|
||||||
userStatus: 'joined',
|
|
||||||
participantCount: r.participantCount + 1,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// If user is authenticated: render Group Rides Management Console
|
||||||
|
if (currentUser) {
|
||||||
|
return (
|
||||||
|
<GroupRidesManagement
|
||||||
|
currentUser={currentUser}
|
||||||
|
onViewShowcase={() => setViewOverride('showcase')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is not authenticated: render Group Rides Feature Showcase Page
|
||||||
return (
|
return (
|
||||||
<div className="flagship-page text-on-surface bg-surface min-h-screen">
|
<GroupRidesShowcase
|
||||||
<div className="flex flex-col w-full">
|
isLoggedIn={false}
|
||||||
{/* Dynamic Atmospheric Glow Background Layers */}
|
onGoToManagement={() => setViewOverride('manage')}
|
||||||
<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">
|
|
||||||
{/* Eyebrow Badge */}
|
|
||||||
<div className="inline-flex items-center gap-space-xs px-space-sm py-1.5 rounded-full bg-surface-container-high border-0 shadow-sm mb-space-md">
|
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Dual CTAs & Quick Metrics Bar */}
|
|
||||||
<div className="flex flex-wrap items-center gap-space-md w-full">
|
|
||||||
<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
|
|
||||||
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 Cockpit */}
|
|
||||||
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
|
||||||
<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>
|
|
||||||
</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-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>
|
|
||||||
<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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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-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>
|
|
||||||
<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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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-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>
|
|
||||||
<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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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-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>
|
|
||||||
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
|
||||||
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 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-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>
|
|
||||||
<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.
|
|
||||||
</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 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 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 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,24 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { onAuthStateChanged, User } from 'firebase/auth';
|
||||||
|
import { firebaseAuth } from '../../../lib/firebase-client';
|
||||||
|
import { GroupRidesShowcase } from '../../../components/group-rides/group-rides-showcase';
|
||||||
|
|
||||||
|
export default function GroupRidesShowcaseRoute() {
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GroupRidesShowcase
|
||||||
|
isLoggedIn={Boolean(currentUser)}
|
||||||
|
userEmail={currentUser?.email}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Keep Ride',
|
||||||
|
danger = true,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteConfirmModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-modal-title"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200"
|
||||||
|
>
|
||||||
|
<div className="relative w-full max-w-md bg-surface-container-low border border-outline-variant/40 rounded-2xl p-6 shadow-2xl space-y-4">
|
||||||
|
{/* Icon & Title */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||||
|
danger
|
||||||
|
? 'bg-error/20 text-error border border-error/30'
|
||||||
|
: 'bg-primary-container/20 text-primary-container border border-primary-container/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[22px]">
|
||||||
|
{danger ? 'warning' : 'help'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 id="confirm-modal-title" className="font-headline-md text-base font-bold text-on-surface">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<span className="font-mono text-[10px] text-on-surface-variant">
|
||||||
|
Formation Telemetry Safeguard
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="font-body-sm text-sm text-on-surface-variant leading-relaxed">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-semibold text-on-surface transition cursor-pointer"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-semibold transition cursor-pointer ${
|
||||||
|
danger
|
||||||
|
? 'bg-error text-on-error hover:bg-error/90 shadow-[0_0_16px_rgba(255,82,82,0.3)]'
|
||||||
|
: 'bg-primary-container text-on-primary-container hover:bg-primary-container/90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { GroupRideSummary, EditRideFormData } from './types';
|
||||||
|
|
||||||
|
interface EditRideModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
ride: GroupRideSummary | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaveRide: (updatedRide: GroupRideSummary) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditRideModal({
|
||||||
|
isOpen,
|
||||||
|
ride,
|
||||||
|
onClose,
|
||||||
|
onSaveRide,
|
||||||
|
}: EditRideModalProps) {
|
||||||
|
const [formData, setFormData] = useState<EditRideFormData>({
|
||||||
|
title: '',
|
||||||
|
region: '',
|
||||||
|
description: '',
|
||||||
|
paceClassification: 'Brisk Pace',
|
||||||
|
formationProfile: 'Staggered Paceline',
|
||||||
|
joinPolicy: 'requestToJoin',
|
||||||
|
scheduledDate: '',
|
||||||
|
scheduledTime: '',
|
||||||
|
distance: '',
|
||||||
|
meetingPoint: '',
|
||||||
|
participantLimit: 12,
|
||||||
|
status: 'scheduled',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ride) {
|
||||||
|
setFormData({
|
||||||
|
title: ride.title,
|
||||||
|
region: ride.region,
|
||||||
|
description: ride.description,
|
||||||
|
paceClassification: ride.paceClassification,
|
||||||
|
formationProfile: ride.formationProfile,
|
||||||
|
joinPolicy: ride.joinPolicy,
|
||||||
|
scheduledDate: ride.scheduledDate,
|
||||||
|
scheduledTime: ride.scheduledTime,
|
||||||
|
distance: ride.distance,
|
||||||
|
meetingPoint: ride.meetingPoint,
|
||||||
|
participantLimit: ride.participantLimit,
|
||||||
|
status: ride.status ?? 'scheduled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ride]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen || !ride) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.title.trim() || !formData.region.trim()) return;
|
||||||
|
|
||||||
|
const updated: GroupRideSummary = {
|
||||||
|
...ride,
|
||||||
|
title: formData.title.trim(),
|
||||||
|
region: formData.region.trim(),
|
||||||
|
description: formData.description.trim(),
|
||||||
|
paceClassification: formData.paceClassification,
|
||||||
|
formationProfile: formData.formationProfile.trim(),
|
||||||
|
joinPolicy: formData.joinPolicy,
|
||||||
|
scheduledDate: formData.scheduledDate.trim(),
|
||||||
|
scheduledTime: formData.scheduledTime.trim(),
|
||||||
|
distance: formData.distance.trim(),
|
||||||
|
meetingPoint: formData.meetingPoint.trim(),
|
||||||
|
participantLimit: formData.participantLimit,
|
||||||
|
status: formData.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
onSaveRide(updated);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="edit-modal-title"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto animate-in fade-in duration-200"
|
||||||
|
>
|
||||||
|
<div className="relative w-full max-w-xl bg-surface-container-low border border-outline-variant/40 rounded-2xl p-6 shadow-2xl space-y-5 my-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-outline-variant/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-primary-container/20 text-primary-container flex items-center justify-center">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">edit_note</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 id="edit-modal-title" className="font-headline-md text-lg font-bold text-on-surface">
|
||||||
|
Alter Group Ride Parameters
|
||||||
|
</h2>
|
||||||
|
<span className="font-mono text-[10px] text-primary-fixed-dim">
|
||||||
|
ID: {ride.id} {ride.rideCode ? `• Code: ${ride.rideCode}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 rounded-lg bg-surface-container hover:bg-surface-container-high text-on-surface-variant hover:text-on-surface flex items-center justify-center transition cursor-pointer"
|
||||||
|
aria-label="Close edit dialog"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-title" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Ride Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container font-sans"
|
||||||
|
placeholder="e.g. Skyline Dawn Patrol"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Region & Rally Point */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-region" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Region / City *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-region"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.region}
|
||||||
|
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
placeholder="e.g. San Francisco, CA"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-meetingPoint" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Rally / Meeting Point
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-meetingPoint"
|
||||||
|
type="text"
|
||||||
|
value={formData.meetingPoint}
|
||||||
|
onChange={(e) => setFormData({ ...formData, meetingPoint: e.target.value })}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
placeholder="e.g. Alice's Restaurant"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date, Time, Distance */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-date" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Date / Day
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-date"
|
||||||
|
type="text"
|
||||||
|
value={formData.scheduledDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
placeholder="e.g. Saturday"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-time" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-time"
|
||||||
|
type="text"
|
||||||
|
value={formData.scheduledTime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, scheduledTime: e.target.value })}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
placeholder="e.g. 06:30 AM PST"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-distance" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Distance / Route
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-distance"
|
||||||
|
type="text"
|
||||||
|
value={formData.distance}
|
||||||
|
onChange={(e) => setFormData({ ...formData, distance: e.target.value })}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
placeholder="e.g. 74 Miles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pace Classification, Status, Rider Limit */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-pace" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Pace Classification
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="edit-pace"
|
||||||
|
value={formData.paceClassification}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
paceClassification: e.target.value as GroupRideSummary['paceClassification'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
>
|
||||||
|
<option value="Track Pace">Track Pace (Advanced)</option>
|
||||||
|
<option value="Brisk Pace">Brisk Pace (Spirited)</option>
|
||||||
|
<option value="Endurance / Chill">Endurance / Chill</option>
|
||||||
|
<option value="Weekend Tours">Weekend Tours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-status" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Ride Lifecycle Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="edit-status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
status: e.target.value as EditRideFormData['status'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="assembling">Assembling at Rally Point</option>
|
||||||
|
<option value="underway">Underway (Live Radar Active)</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="canceled">Canceled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-limit" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Max Pack Riders
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-limit"
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
max="50"
|
||||||
|
value={formData.participantLimit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, participantLimit: parseInt(e.target.value) || 10 })
|
||||||
|
}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-description" className="block text-xs font-semibold text-on-surface mb-1">
|
||||||
|
Ride Briefing & Safety Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-description"
|
||||||
|
rows={3}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-surface-container border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary-container"
|
||||||
|
placeholder="Outline corner entry rules, checkpoint stops, and radar frequency..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-3 border-t border-outline-variant/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-semibold text-on-surface transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container text-xs font-bold hover:shadow-[0_0_20px_rgba(0,242,254,0.4)] transition cursor-pointer flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">save</span>
|
||||||
|
<span>Save Changes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -112,7 +112,11 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
||||||
participantLimit: 16,
|
participantLimit: 16,
|
||||||
distance: '82.4 Miles',
|
distance: '82.4 Miles',
|
||||||
meetingPoint: "Alice's Restaurant, Skyline Blvd",
|
meetingPoint: "Alice's Restaurant, Skyline Blvd",
|
||||||
userStatus: 'none',
|
userStatus: 'joined',
|
||||||
|
isOrganizer: true,
|
||||||
|
status: 'scheduled',
|
||||||
|
rideCode: 'APEX84',
|
||||||
|
clubName: 'NorCal Apex Guild',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ride-malibu-canyons',
|
id: 'ride-malibu-canyons',
|
||||||
|
|
@ -130,7 +134,11 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
||||||
participantLimit: 12,
|
participantLimit: 12,
|
||||||
distance: '64.0 Miles',
|
distance: '64.0 Miles',
|
||||||
meetingPoint: 'Rock Store Overlook',
|
meetingPoint: 'Rock Store Overlook',
|
||||||
userStatus: 'none',
|
userStatus: 'joined',
|
||||||
|
isOrganizer: false,
|
||||||
|
status: 'scheduled',
|
||||||
|
rideCode: 'MALIBU22',
|
||||||
|
clubName: 'Pacific Coast Riders',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ride-tail-of-dragon',
|
id: 'ride-tail-of-dragon',
|
||||||
|
|
@ -149,6 +157,9 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
||||||
distance: '318 Curves',
|
distance: '318 Curves',
|
||||||
meetingPoint: 'Deal’s Gap Motorcycle Resort',
|
meetingPoint: 'Deal’s Gap Motorcycle Resort',
|
||||||
userStatus: 'none',
|
userStatus: 'none',
|
||||||
|
isOrganizer: false,
|
||||||
|
status: 'scheduled',
|
||||||
|
rideCode: 'DRAGON318',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ride-angeles-crest',
|
id: 'ride-angeles-crest',
|
||||||
|
|
@ -167,6 +178,9 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
||||||
distance: '52.8 Miles',
|
distance: '52.8 Miles',
|
||||||
meetingPoint: 'Shell Station at Foothill Blvd',
|
meetingPoint: 'Shell Station at Foothill Blvd',
|
||||||
userStatus: 'none',
|
userStatus: 'none',
|
||||||
|
isOrganizer: false,
|
||||||
|
status: 'scheduled',
|
||||||
|
rideCode: 'CREST06',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ride-lolo-pass',
|
id: 'ride-lolo-pass',
|
||||||
|
|
@ -185,5 +199,8 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
||||||
distance: '134.5 Miles',
|
distance: '134.5 Miles',
|
||||||
meetingPoint: 'Lolo Hot Springs Rest Area',
|
meetingPoint: 'Lolo Hot Springs Rest Area',
|
||||||
userStatus: 'none',
|
userStatus: 'none',
|
||||||
|
isOrganizer: false,
|
||||||
|
status: 'scheduled',
|
||||||
|
rideCode: 'LOLO99',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,849 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { INITIAL_GROUP_RIDES } from './fixtures';
|
||||||
|
import { GroupRideSummary } from './types';
|
||||||
|
import { CreateRideModal } from './create-ride-modal';
|
||||||
|
import { EditRideModal } from './edit-ride-modal';
|
||||||
|
import { DeleteConfirmModal } from './delete-confirm-modal';
|
||||||
|
import { JoinRideModal } from './join-ride-modal';
|
||||||
|
import { LivePackRadar } from './live-pack-radar';
|
||||||
|
|
||||||
|
interface GroupRidesManagementProps {
|
||||||
|
currentUser?: { email?: string | null; displayName?: string | null } | null;
|
||||||
|
onViewShowcase?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagementTab = 'my-rides' | 'discover' | 'radar';
|
||||||
|
type MyRidesFilter = 'all' | 'organized' | 'joined';
|
||||||
|
|
||||||
|
export function GroupRidesManagement({
|
||||||
|
currentUser,
|
||||||
|
onViewShowcase,
|
||||||
|
}: GroupRidesManagementProps) {
|
||||||
|
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
|
||||||
|
const [activeTab, setActiveTab] = useState<ManagementTab>('my-rides');
|
||||||
|
const [myRidesFilter, setMyRidesFilter] = useState<MyRidesFilter>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [paceFilter, setPaceFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
// Modals state
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [isJoinModalOpen, setIsJoinModalOpen] = useState(false);
|
||||||
|
const [editingRide, setEditingRide] = useState<GroupRideSummary | null>(null);
|
||||||
|
const [deletingRide, setDeletingRide] = useState<GroupRideSummary | null>(null);
|
||||||
|
const [leavingRide, setLeavingRide] = useState<GroupRideSummary | null>(null);
|
||||||
|
|
||||||
|
// Toast alert
|
||||||
|
const [activeAlert, setActiveAlert] = useState<{
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'info' | 'error';
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const showAlert = (message: string, type: 'success' | 'info' | 'error' = 'success') => {
|
||||||
|
setActiveAlert({ message, type });
|
||||||
|
setTimeout(() => {
|
||||||
|
setActiveAlert(null);
|
||||||
|
}, 4500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
const myOrganizedRides = useMemo(() => rides.filter((r) => r.isOrganizer), [rides]);
|
||||||
|
const myJoinedRides = useMemo(
|
||||||
|
() => rides.filter((r) => !r.isOrganizer && (r.userStatus === 'joined' || r.userStatus === 'requested')),
|
||||||
|
[rides]
|
||||||
|
);
|
||||||
|
const myTotalRides = useMemo(
|
||||||
|
() => rides.filter((r) => r.isOrganizer || r.userStatus === 'joined'),
|
||||||
|
[rides]
|
||||||
|
);
|
||||||
|
|
||||||
|
// My Rides filtered list
|
||||||
|
const filteredMyRides = useMemo(() => {
|
||||||
|
let list = rides.filter((r) => {
|
||||||
|
if (myRidesFilter === 'organized') return r.isOrganizer;
|
||||||
|
if (myRidesFilter === 'joined') return !r.isOrganizer && (r.userStatus === 'joined' || r.userStatus === 'requested');
|
||||||
|
return r.isOrganizer || r.userStatus === 'joined' || r.userStatus === 'requested';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(r) =>
|
||||||
|
r.title.toLowerCase().includes(q) ||
|
||||||
|
r.region.toLowerCase().includes(q) ||
|
||||||
|
r.meetingPoint.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [rides, myRidesFilter, searchQuery]);
|
||||||
|
|
||||||
|
// Discover Rides filtered list
|
||||||
|
const discoverRides = useMemo(() => {
|
||||||
|
let list = rides;
|
||||||
|
if (paceFilter !== 'all') {
|
||||||
|
list = list.filter((r) => r.paceClassification.toLowerCase().includes(paceFilter.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(r) =>
|
||||||
|
r.title.toLowerCase().includes(q) ||
|
||||||
|
r.region.toLowerCase().includes(q) ||
|
||||||
|
r.leadHandle.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [rides, paceFilter, searchQuery]);
|
||||||
|
|
||||||
|
// Handlers for Add, Alter, Remove, Join, Leave
|
||||||
|
const handleCreateRide = (newRide: GroupRideSummary) => {
|
||||||
|
const rideWithOrganizer: GroupRideSummary = {
|
||||||
|
...newRide,
|
||||||
|
isOrganizer: true,
|
||||||
|
status: 'scheduled',
|
||||||
|
userStatus: 'joined',
|
||||||
|
rideCode: `MS-${Math.floor(1000 + Math.random() * 9000)}`,
|
||||||
|
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
|
||||||
|
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
|
||||||
|
};
|
||||||
|
setRides((prev) => [rideWithOrganizer, ...prev]);
|
||||||
|
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideWithOrganizer.rideCode}`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEditedRide = (updatedRide: GroupRideSummary) => {
|
||||||
|
setRides((prev) => prev.map((r) => (r.id === updatedRide.id ? updatedRide : r)));
|
||||||
|
showAlert(`Updated parameters for "${updatedRide.title}".`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRide = () => {
|
||||||
|
if (!deletingRide) return;
|
||||||
|
const title = deletingRide.title;
|
||||||
|
setRides((prev) => prev.filter((r) => r.id !== deletingRide.id));
|
||||||
|
setDeletingRide(null);
|
||||||
|
showAlert(`Deleted group ride "${title}". Pack telemetry cleared.`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRide = (ride: GroupRideSummary) => {
|
||||||
|
setRides((prev) =>
|
||||||
|
prev.map((r) => (r.id === ride.id ? { ...r, status: 'canceled' } : r))
|
||||||
|
);
|
||||||
|
showAlert(`Ride "${ride.title}" status changed to Canceled. Participants notified.`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeaveRide = () => {
|
||||||
|
if (!leavingRide) return;
|
||||||
|
const title = leavingRide.title;
|
||||||
|
setRides((prev) =>
|
||||||
|
prev.map((r) => {
|
||||||
|
if (r.id !== leavingRide.id) return r;
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
userStatus: 'none',
|
||||||
|
participantCount: Math.max(0, r.participantCount - 1),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setLeavingRide(null);
|
||||||
|
showAlert(`Left pack for "${title}". Slot relinquished.`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
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}".`, 'info');
|
||||||
|
return { ...r, userStatus: 'waitlisted' };
|
||||||
|
}
|
||||||
|
showAlert(`Joined pack for "${r.title}"! Telemetry stream linked.`, 'success');
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
userStatus: 'joined',
|
||||||
|
participantCount: r.participantCount + 1,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinSuccess = (code: string, rideTitle: string) => {
|
||||||
|
showAlert(`Synchronized telemetry with "${rideTitle}" (Code: ${code})!`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyRideCode = (code?: string) => {
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard?.writeText(code);
|
||||||
|
showAlert(`Copied ride code "${code}" to clipboard!`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flagship-page text-on-surface bg-surface min-h-screen">
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
{/* Dynamic Atmospheric Glow Background Layers */}
|
||||||
|
<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'
|
||||||
|
: activeAlert.type === 'error'
|
||||||
|
? 'bg-error-container/90 text-on-error-container border-error/40'
|
||||||
|
: 'bg-surface-container-high/90 text-primary border-primary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
{activeAlert.type === 'success' ? 'check_circle' : activeAlert.type === 'error' ? 'error' : '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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Command Bar & Showcase Switch */}
|
||||||
|
<div className="bg-surface-container-low border-b border-outline-variant/30 py-3 px-4 sm:px-8">
|
||||||
|
<div className="max-w-container-max mx-auto flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<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 w-2 bg-secondary-fixed-dim"></span>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs font-semibold text-secondary-fixed">
|
||||||
|
Rider Console Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-outline-variant/40 hidden sm:inline">•</span>
|
||||||
|
<span className="text-xs text-on-surface-variant hidden sm:inline font-mono">
|
||||||
|
{currentUser?.email ? currentUser.email : 'Rider Account'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Showcase Mode Link */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{onViewShowcase ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onViewShowcase}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-semibold text-on-surface hover:text-primary-container transition cursor-pointer"
|
||||||
|
title="View public features showcase"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px] text-primary-container">
|
||||||
|
visibility
|
||||||
|
</span>
|
||||||
|
<span>Feature Showcase</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/group-rides?view=showcase"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-semibold text-on-surface hover:text-primary-container transition"
|
||||||
|
title="View public features showcase"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px] text-primary-container">
|
||||||
|
visibility
|
||||||
|
</span>
|
||||||
|
<span>Feature Showcase</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/account"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-semibold text-on-surface transition"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px] text-secondary">
|
||||||
|
person
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline">Rider Portal</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header & Quick Action Region */}
|
||||||
|
<section className="max-w-container-max mx-auto px-gutter-desktop pt-space-xl pb-space-xl">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6 pb-space-lg border-b border-outline-variant/20">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary-container/15 border border-primary-container/30 text-primary-container text-xs font-mono font-semibold mb-2">
|
||||||
|
<span className="material-symbols-outlined text-[16px]">groups</span>
|
||||||
|
<span>Squad Management & Telemetry Hub</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight">
|
||||||
|
Group Rides Management
|
||||||
|
</h1>
|
||||||
|
<p className="font-body-md text-sm sm:text-base text-on-surface-variant max-w-2xl mt-1">
|
||||||
|
Schedule new pack formations, modify routes, manage roster slots, and monitor live squadron
|
||||||
|
mesh telemetry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="px-5 py-3 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-xs sm:text-sm font-bold shadow-lg hover:shadow-[0_0_20px_rgba(0,242,254,0.45)] transition cursor-pointer flex items-center gap-2"
|
||||||
|
id="btn-create-ride-mgmt"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">add_circle</span>
|
||||||
|
<span>Create Group Ride</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsJoinModalOpen(true)}
|
||||||
|
className="px-4 py-3 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-outline-variant/30 text-on-surface font-headline-sm text-xs sm:text-sm font-semibold transition cursor-pointer flex items-center gap-2"
|
||||||
|
id="btn-join-code-mgmt"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-primary-fixed-dim text-[18px]">
|
||||||
|
qr_code_scanner
|
||||||
|
</span>
|
||||||
|
<span>Join with Code</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mt-6">
|
||||||
|
<div className="p-4 rounded-2xl bg-surface-container-low border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="font-mono text-xs text-on-surface-variant">Active Pack Runs</span>
|
||||||
|
<div className="text-2xl font-extrabold font-mono text-on-surface mt-1">
|
||||||
|
{myTotalRides.length}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono text-secondary-fixed mt-1">
|
||||||
|
{myOrganizedRides.length} Lead • {myJoinedRides.length} Wingman
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-2xl bg-surface-container-low border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="font-mono text-xs text-on-surface-variant">Rides Organized</span>
|
||||||
|
<div className="text-2xl font-extrabold font-mono text-primary-container mt-1">
|
||||||
|
{myOrganizedRides.length}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono text-on-surface-variant mt-1">
|
||||||
|
Road Captain Authority
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-2xl bg-surface-container-low border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="font-mono text-xs text-on-surface-variant">Squad Formations</span>
|
||||||
|
<div className="text-2xl font-extrabold font-mono text-secondary-fixed mt-1">
|
||||||
|
{rides.length} Total
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono text-on-surface-variant mt-1">
|
||||||
|
In Regional Network
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-2xl bg-surface-container-low border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="font-mono text-xs text-on-surface-variant">Live Pack Radar</span>
|
||||||
|
<div className="text-2xl font-extrabold font-mono text-primary-fixed mt-1">
|
||||||
|
Online
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono text-secondary-fixed mt-1">
|
||||||
|
6-Node Telemetry Mesh
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main Navigation Tabs */}
|
||||||
|
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-2xl">
|
||||||
|
<div className="flex border-b border-outline-variant/20 gap-2 sm:gap-6 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('my-rides')}
|
||||||
|
className={`pb-3 text-sm font-semibold transition whitespace-nowrap flex items-center gap-2 cursor-pointer border-b-2 ${
|
||||||
|
activeTab === 'my-rides'
|
||||||
|
? 'border-primary-container text-primary-container font-bold'
|
||||||
|
: 'border-transparent text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">sports_motorsports</span>
|
||||||
|
<span>My Pack Runs ({myTotalRides.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('discover')}
|
||||||
|
className={`pb-3 text-sm font-semibold transition whitespace-nowrap flex items-center gap-2 cursor-pointer border-b-2 ${
|
||||||
|
activeTab === 'discover'
|
||||||
|
? 'border-primary-container text-primary-container font-bold'
|
||||||
|
: 'border-transparent text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">explore</span>
|
||||||
|
<span>Explore Community Rides ({rides.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('radar')}
|
||||||
|
className={`pb-3 text-sm font-semibold transition whitespace-nowrap flex items-center gap-2 cursor-pointer border-b-2 ${
|
||||||
|
activeTab === 'radar'
|
||||||
|
? 'border-primary-container text-primary-container font-bold'
|
||||||
|
: 'border-transparent text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">radar</span>
|
||||||
|
<span>Live Squad Radar Cockpit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* TAB 1: MY PACK RUNS (VIEW, ALTER, REMOVE, LEAVE) */}
|
||||||
|
{activeTab === 'my-rides' && (
|
||||||
|
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||||
|
{/* Filter Sub-Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 bg-surface-container-low p-1 rounded-xl border border-outline-variant/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMyRidesFilter('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||||
|
myRidesFilter === 'all'
|
||||||
|
? 'bg-surface-container-high text-on-surface'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All My Rides ({myTotalRides.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMyRidesFilter('organized')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||||
|
myRidesFilter === 'organized'
|
||||||
|
? 'bg-surface-container-high text-primary-container'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Organized by Me ({myOrganizedRides.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMyRidesFilter('joined')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||||
|
myRidesFilter === 'joined'
|
||||||
|
? 'bg-surface-container-high text-secondary-fixed'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Joined as Wingman ({myJoinedRides.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="relative max-w-xs w-full">
|
||||||
|
<span className="material-symbols-outlined absolute left-3 top-2.5 text-on-surface-variant text-[18px]">
|
||||||
|
search
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search my pack runs..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3.5 py-1.5 rounded-xl bg-surface-container border border-outline-variant/30 text-xs text-on-surface focus:outline-none focus:ring-1 focus:ring-primary-container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rides List */}
|
||||||
|
{filteredMyRides.length === 0 ? (
|
||||||
|
<div className="text-center py-16 px-4 bg-surface-container-low rounded-2xl border border-outline-variant/20 space-y-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-surface-container-high mx-auto flex items-center justify-center text-primary-container">
|
||||||
|
<span className="material-symbols-outlined text-[28px]">sports_motorsports</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-headline-md text-base font-bold text-on-surface">
|
||||||
|
No group rides in this view
|
||||||
|
</h3>
|
||||||
|
<p className="font-body-sm text-xs text-on-surface-variant mt-1 max-w-md mx-auto">
|
||||||
|
Schedule a new run to lead as Road Captain, or browse the community directory to join an upcoming pack run.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-primary-container text-on-primary-container text-xs font-bold"
|
||||||
|
>
|
||||||
|
+ Create Group Ride
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('discover')}
|
||||||
|
className="px-4 py-2 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-semibold text-on-surface"
|
||||||
|
>
|
||||||
|
Browse Community Runs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-lg">
|
||||||
|
{filteredMyRides.map((ride) => {
|
||||||
|
const isOrg = Boolean(ride.isOrganizer);
|
||||||
|
const isCanceled = ride.status === 'canceled';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ride.id}
|
||||||
|
className={`rounded-2xl border flex flex-col justify-between transition-all shadow-md overflow-hidden ${
|
||||||
|
isCanceled
|
||||||
|
? 'bg-surface-container-low/60 border-error/30 opacity-75'
|
||||||
|
: 'bg-surface-container-low border-outline-variant/30 hover:border-outline-variant/60 hover:shadow-xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-space-lg">
|
||||||
|
{/* Role & Status Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isOrg ? (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-primary-container/20 text-primary-container border border-primary-container/30 text-[10px] font-mono font-bold">
|
||||||
|
Road Captain (Organizer)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-secondary-fixed/20 text-secondary-fixed border border-secondary-fixed/30 text-[10px] font-mono font-bold">
|
||||||
|
Squad Wingman
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCanceled && (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-error/20 text-error border border-error/30 text-[10px] font-mono font-bold">
|
||||||
|
Canceled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ride Code Button */}
|
||||||
|
{ride.rideCode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyRideCode(ride.rideCode)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-surface-container text-[10px] font-mono text-on-surface-variant hover:text-primary-container transition cursor-pointer"
|
||||||
|
title="Click to copy ride code"
|
||||||
|
>
|
||||||
|
<span>{ride.rideCode}</span>
|
||||||
|
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ride Title & Region */}
|
||||||
|
<h3 className="font-headline-md text-lg font-bold text-on-surface mb-1">
|
||||||
|
{ride.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-mono text-on-surface-variant mb-2">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-secondary-fixed">
|
||||||
|
location_on
|
||||||
|
</span>
|
||||||
|
<span>{ride.region}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{ride.distance}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-body-sm text-xs text-on-surface-variant line-clamp-2 mb-3">
|
||||||
|
{ride.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Meeting Point & Schedule */}
|
||||||
|
<div className="p-2.5 rounded-xl bg-surface-container/60 border border-outline-variant/20 text-xs font-mono space-y-1 mb-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-on-surface">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-primary-container">
|
||||||
|
schedule
|
||||||
|
</span>
|
||||||
|
<span>{ride.scheduledDate} • {ride.scheduledTime}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-on-surface-variant truncate">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-secondary-fixed">
|
||||||
|
flag
|
||||||
|
</span>
|
||||||
|
<span className="truncate">Rally: {ride.meetingPoint}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-on-surface-variant">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-primary-fixed-dim">
|
||||||
|
group
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Pack: {ride.participantCount}/{ride.participantLimit} Riders
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Management Controls Row (ALTER, REMOVE, CANCEL, LEAVE) */}
|
||||||
|
<div className="p-space-lg pt-0 border-t border-outline-variant/20 mt-1 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
{isOrg ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingRide(ride)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-primary-container/15 hover:bg-primary-container/25 text-primary-container border border-primary-container/30 text-xs font-semibold transition cursor-pointer flex items-center gap-1"
|
||||||
|
title="Alter ride parameters"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[15px]">edit</span>
|
||||||
|
<span>Alter / Edit</span>
|
||||||
|
</button>
|
||||||
|
{!isCanceled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCancelRide(ride)}
|
||||||
|
className="px-2.5 py-1.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-xs font-semibold text-on-surface-variant hover:text-error transition cursor-pointer"
|
||||||
|
title="Cancel ride"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeletingRide(ride)}
|
||||||
|
className="px-2.5 py-1.5 rounded-xl text-xs font-semibold text-error hover:bg-error/10 transition cursor-pointer flex items-center gap-1"
|
||||||
|
title="Delete group ride permanently"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[15px]">delete</span>
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-mono text-secondary-fixed flex items-center gap-1">
|
||||||
|
<span className="material-symbols-outlined text-[16px]">check_circle</span>
|
||||||
|
<span>Pack Slot Active</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLeavingRide(ride)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-surface-container hover:bg-error/15 text-error border border-outline-variant/30 hover:border-error/40 text-xs font-semibold transition cursor-pointer flex items-center gap-1"
|
||||||
|
title="Leave group ride"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[15px]">logout</span>
|
||||||
|
<span>Leave Pack</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: EXPLORE COMMUNITY RUNS (VIEW, SEARCH, REQUEST SLOT) */}
|
||||||
|
{activeTab === 'discover' && (
|
||||||
|
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||||
|
{/* Filter controls */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-surface-container-low p-1 rounded-xl border border-outline-variant/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaceFilter('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||||
|
paceFilter === 'all'
|
||||||
|
? 'bg-surface-container-high text-on-surface font-bold'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Runs ({rides.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaceFilter('Brisk')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||||
|
paceFilter === 'Brisk'
|
||||||
|
? 'bg-surface-container-high text-on-surface font-bold'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Brisk Pace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaceFilter('Track')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||||
|
paceFilter === 'Track'
|
||||||
|
? 'bg-surface-container-high text-on-surface font-bold'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Track Pace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaceFilter('Tour')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||||
|
paceFilter === 'Tour'
|
||||||
|
? 'bg-surface-container-high text-on-surface font-bold'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tours & Chill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative max-w-sm w-full">
|
||||||
|
<span className="material-symbols-outlined absolute left-3 top-2.5 text-on-surface-variant text-[18px]">
|
||||||
|
search
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by title, city, or lead..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3.5 py-1.5 rounded-xl bg-surface-container border border-outline-variant/30 text-xs text-on-surface focus:outline-none focus:ring-1 focus:ring-primary-container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Directory Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-lg">
|
||||||
|
{discoverRides.map((ride) => {
|
||||||
|
const isJoined = ride.userStatus === 'joined' || ride.isOrganizer;
|
||||||
|
const isFull = ride.participantCount >= ride.participantLimit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ride.id}
|
||||||
|
className="bg-surface-container-low rounded-2xl border border-outline-variant/30 overflow-hidden shadow-md hover:shadow-xl transition flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div className="p-space-lg">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<span className="font-mono text-[10px] uppercase px-2 py-0.5 rounded bg-surface-container-high text-primary-container border border-outline-variant/30 font-semibold">
|
||||||
|
{ride.paceClassification}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-on-surface-variant">
|
||||||
|
{ride.scheduledDate} • {ride.scheduledTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-headline-md text-base font-bold text-on-surface mb-1">
|
||||||
|
{ride.title}
|
||||||
|
</h3>
|
||||||
|
<p className="font-body-sm text-xs text-on-surface-variant line-clamp-2 mb-3">
|
||||||
|
{ride.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-xs font-mono text-on-surface-variant mb-3">
|
||||||
|
<div className="flex items-center gap-1.5 truncate">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-secondary-fixed">
|
||||||
|
location_on
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{ride.region}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 truncate">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-primary-container">
|
||||||
|
flag
|
||||||
|
</span>
|
||||||
|
<span className="truncate">Rally: {ride.meetingPoint}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-outline-variant/20 text-xs font-mono text-on-surface-variant">
|
||||||
|
<span>Lead: {ride.leadHandle}</span>
|
||||||
|
<span className={isFull ? 'text-error font-bold' : 'text-secondary-fixed'}>
|
||||||
|
{ride.participantCount}/{ride.participantLimit} Riders
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-space-lg pt-0">
|
||||||
|
{isJoined ? (
|
||||||
|
<div className="w-full py-2 rounded-xl bg-secondary-fixed/20 border border-secondary-fixed/30 text-secondary-fixed text-xs font-bold flex items-center justify-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined text-[16px]">check_circle</span>
|
||||||
|
<span>{ride.isOrganizer ? 'Organized by You' : 'Slot Confirmed'}</span>
|
||||||
|
</div>
|
||||||
|
) : isFull ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRequestSlot(ride.id)}
|
||||||
|
className="w-full py-2 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface text-xs font-semibold transition"
|
||||||
|
>
|
||||||
|
Join Waitlist
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRequestSlot(ride.id)}
|
||||||
|
className="w-full py-2 rounded-xl bg-primary-container hover:bg-primary-fixed text-on-primary-container text-xs font-bold transition flex items-center justify-center gap-1.5 shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">how_to_reg</span>
|
||||||
|
<span>Request Pack Slot</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 3: LIVE SQUAD RADAR COCKPIT */}
|
||||||
|
{activeTab === 'radar' && (
|
||||||
|
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-mono text-xs text-primary-container font-semibold block mb-1">
|
||||||
|
Active Squad Telemetry Stream
|
||||||
|
</span>
|
||||||
|
<h2 className="font-headline-lg text-xl font-bold text-on-surface">
|
||||||
|
Live Formation Radar
|
||||||
|
</h2>
|
||||||
|
<p className="font-body-sm text-xs text-on-surface-variant mt-0.5">
|
||||||
|
Synchronized multi-band telemetry mesh monitoring live squad distance, speed, and lean angle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LivePackRadar initialMode="tactical" />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<CreateRideModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreateRide={handleCreateRide}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditRideModal
|
||||||
|
isOpen={Boolean(editingRide)}
|
||||||
|
ride={editingRide}
|
||||||
|
onClose={() => setEditingRide(null)}
|
||||||
|
onSaveRide={handleSaveEditedRide}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmModal
|
||||||
|
isOpen={Boolean(deletingRide)}
|
||||||
|
title="Delete Group Ride"
|
||||||
|
message={`Are you sure you want to permanently delete "${deletingRide?.title}"? All telemetry sync channels, participant slots, and ride history will be deleted.`}
|
||||||
|
confirmText="Delete Ride"
|
||||||
|
cancelText="Keep Ride"
|
||||||
|
danger={true}
|
||||||
|
onClose={() => setDeletingRide(null)}
|
||||||
|
onConfirm={handleDeleteRide}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmModal
|
||||||
|
isOpen={Boolean(leavingRide)}
|
||||||
|
title="Leave Group Ride"
|
||||||
|
message={`Are you sure you want to leave the pack for "${leavingRide?.title}"? Your telemetry channel will be disconnected and your slot will be released.`}
|
||||||
|
confirmText="Leave Pack"
|
||||||
|
cancelText="Stay in Pack"
|
||||||
|
danger={false}
|
||||||
|
onClose={() => setLeavingRide(null)}
|
||||||
|
onConfirm={handleLeaveRide}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<JoinRideModal
|
||||||
|
isOpen={isJoinModalOpen}
|
||||||
|
onClose={() => setIsJoinModalOpen(false)}
|
||||||
|
onJoinSuccess={handleJoinSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { INITIAL_GROUP_RIDES } from './fixtures';
|
||||||
|
import { GroupRideSummary } from './types';
|
||||||
|
import { LivePackRadar } from './live-pack-radar';
|
||||||
|
|
||||||
|
interface GroupRidesShowcaseProps {
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
userEmail?: string | null;
|
||||||
|
onGoToManagement?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupRidesShowcase({
|
||||||
|
isLoggedIn = false,
|
||||||
|
userEmail,
|
||||||
|
onGoToManagement,
|
||||||
|
}: GroupRidesShowcaseProps) {
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'brisk' | 'track' | 'tour'>('all');
|
||||||
|
const [sampleRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
|
||||||
|
|
||||||
|
const filteredRides = sampleRides.filter((ride) => {
|
||||||
|
if (selectedFilter === 'brisk') return ride.paceClassification === 'Brisk Pace';
|
||||||
|
if (selectedFilter === 'track') return ride.paceClassification === 'Track Pace';
|
||||||
|
if (selectedFilter === 'tour')
|
||||||
|
return (
|
||||||
|
ride.paceClassification === 'Weekend Tours' ||
|
||||||
|
ride.paceClassification === 'Endurance / Chill'
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flagship-page text-on-surface bg-surface min-h-screen">
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
{/* Dynamic Atmospheric Glow Background Layers */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Authenticated Rider Quick Notice (if logged-in user visits showcase) */}
|
||||||
|
{isLoggedIn && (
|
||||||
|
<div className="bg-primary-container/15 border-b border-primary-container/30 py-2.5 px-4">
|
||||||
|
<div className="max-w-container-max mx-auto flex items-center justify-between text-xs font-semibold">
|
||||||
|
<div className="flex items-center gap-2 text-primary-container">
|
||||||
|
<span className="material-symbols-outlined text-[18px]">verified_user</span>
|
||||||
|
<span>
|
||||||
|
Viewing Public Showcase {userEmail ? `as ${userEmail}` : '(Signed In)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{onGoToManagement ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGoToManagement}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary-container text-on-primary-container font-bold hover:shadow-[0_0_12px_rgba(0,242,254,0.4)] transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>Go to Group Rides Console</span>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/group-rides?view=manage"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary-container text-on-primary-container font-bold hover:shadow-[0_0_12px_rgba(0,242,254,0.4)] transition"
|
||||||
|
>
|
||||||
|
<span>Go to Group Rides Console</span>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{/* Eyebrow Badge */}
|
||||||
|
<div className="inline-flex items-center gap-space-xs px-space-sm py-1.5 rounded-full bg-surface-container-high border-0 shadow-sm mb-space-md">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<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">
|
||||||
|
Formation Telemetry Showcase
|
||||||
|
</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>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Showcase Action CTAs */}
|
||||||
|
<div className="flex flex-wrap items-center gap-space-md w-full">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
{onGoToManagement ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGoToManagement}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">dashboard</span>
|
||||||
|
Manage My Group Rides
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/group-rides?view=manage"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">dashboard</span>
|
||||||
|
Manage My Group Rides
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="#radar-cockpit"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-primary-fixed-dim text-[20px]">
|
||||||
|
radar
|
||||||
|
</span>
|
||||||
|
Explore Radar Simulation
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">how_to_reg</span>
|
||||||
|
Get Started Free
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login?redirect=/group-rides"
|
||||||
|
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 border border-outline-variant/30"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-primary-fixed-dim text-[20px]">
|
||||||
|
login
|
||||||
|
</span>
|
||||||
|
Sign In to Join Runs
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="#radar-cockpit"
|
||||||
|
className="inline-flex items-center justify-center gap-space-xs px-space-md py-3.5 rounded-xl text-on-surface-variant hover:text-on-surface font-label-md text-label-md transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">expand_more</span>
|
||||||
|
Live Radar Demo
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Interactive Live Pack Radar Cockpit */}
|
||||||
|
<section id="radar-cockpit" className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl scroll-mt-24">
|
||||||
|
<LivePackRadar initialMode="tactical" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Group Ride Features Grid (6 Core Subsystems) */}
|
||||||
|
<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">
|
||||||
|
Formation 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, mountain passes, and dense urban corridors.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-lg">
|
||||||
|
{/* Feature 1: Paceline Audit */}
|
||||||
|
<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 Spacing
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>Latency: <12ms</span>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 2: Crash & Separation Alerts */}
|
||||||
|
<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>
|
||||||
|
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Autonomous 6-axis IMU impact detection pings the entire pack instantly. Visual yellow and
|
||||||
|
red telemetry flags highlight 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 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: P2P Mesh */}
|
||||||
|
<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>
|
||||||
|
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Never rely on cellular towers in deep canyons. Ad-hoc Bluetooth and Wi-Fi Direct
|
||||||
|
protocols daisy-chain vital bike metrics from road 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 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: Mission Debrief */}
|
||||||
|
<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">
|
||||||
|
3D Multi-Bike Replay
|
||||||
|
</h3>
|
||||||
|
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Relive the run with synced multi-bike timelines. Compare apex entries, sector speeds,
|
||||||
|
and individual lean angles across the entire squad simultaneously.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Feature 5: Multi-Band GNSS Formation Lock */}
|
||||||
|
<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-dim mb-space-lg">
|
||||||
|
<span className="material-symbols-outlined text-[26px]">satellite_alt</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-code-mono text-code-mono text-secondary-fixed block mb-1">
|
||||||
|
05 / Precision Mesh
|
||||||
|
</span>
|
||||||
|
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||||
|
Multi-Band GNSS Lock
|
||||||
|
</h3>
|
||||||
|
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Dual-frequency L1+L5 satellite tracking ensures accurate relative positioning even in
|
||||||
|
dense canyon canopy where consumer navigation drifts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-space-lg mt-space-lg flex items-center justify-between font-code-mono text-code-mono text-secondary-fixed-dim border-t border-outline-variant/20">
|
||||||
|
<span>Sub-Meter Precision</span>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 6: Dynamic Checkpoint Auto-Regroup */}
|
||||||
|
<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]">pin_drop</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-code-mono text-code-mono text-primary-container block mb-1">
|
||||||
|
06 / Intelligent Checkpoints
|
||||||
|
</span>
|
||||||
|
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||||
|
Auto-Regroup Checkpoints
|
||||||
|
</h3>
|
||||||
|
<p className="font-body-sm text-body-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Automatic rally waypoints notify lead marshals when the pack spreads beyond safety
|
||||||
|
parameters, ensuring no rider is left behind at remote turnouts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>Smart Geofencing</span>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Hardware & Intercom Strip */}
|
||||||
|
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||||
|
<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>
|
||||||
|
<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 telemetry cues without taking your eyes off the corner exit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-space-md">
|
||||||
|
<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 Audio</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<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-dim">sensors</span>
|
||||||
|
<span className="font-code-mono text-code-mono text-on-surface">CAN-Link Telemetry</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sample Community Runs Preview */}
|
||||||
|
<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">
|
||||||
|
Community Network Preview
|
||||||
|
</span>
|
||||||
|
<h2 className="font-headline-xl text-headline-xl text-on-surface tracking-tight">
|
||||||
|
Sample Pack Formations
|
||||||
|
</h2>
|
||||||
|
<p className="font-body-sm text-on-surface-variant mt-1">
|
||||||
|
Preview public squadron runs organized by regional riders across North America.
|
||||||
|
</p>
|
||||||
|
</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={() => setSelectedFilter('all')}
|
||||||
|
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
|
||||||
|
selectedFilter === 'all'
|
||||||
|
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All ({sampleRides.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedFilter('brisk')}
|
||||||
|
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
|
||||||
|
selectedFilter === 'brisk'
|
||||||
|
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Brisk Pace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedFilter('track')}
|
||||||
|
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
|
||||||
|
selectedFilter === 'track'
|
||||||
|
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Track Pace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedFilter('tour')}
|
||||||
|
className={`px-space-md py-1.5 rounded-lg font-label-md text-label-md transition-all ${
|
||||||
|
selectedFilter === 'tour'
|
||||||
|
? 'bg-surface-container-highest text-on-surface font-bold shadow-sm'
|
||||||
|
: 'text-on-surface-variant hover:text-on-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tours
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Showcase Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-lg">
|
||||||
|
{filteredRides.map((ride) => (
|
||||||
|
<div
|
||||||
|
key={ride.id}
|
||||||
|
className="bg-surface-container-low rounded-2xl border border-outline-variant/30 overflow-hidden shadow-md hover:shadow-xl transition-all flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div className="p-space-lg">
|
||||||
|
{/* Header Badges */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-space-sm">
|
||||||
|
<span className="font-mono text-[10px] uppercase px-2 py-0.5 rounded bg-surface-container-high text-primary-container border border-outline-variant/30 font-semibold">
|
||||||
|
{ride.paceClassification}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-on-surface-variant flex items-center gap-1">
|
||||||
|
<span className="material-symbols-outlined text-[14px]">calendar_today</span>
|
||||||
|
{ride.scheduledDate} • {ride.scheduledTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-headline-md text-lg text-on-surface font-bold mb-1">
|
||||||
|
{ride.title}
|
||||||
|
</h3>
|
||||||
|
<p className="font-body-sm text-xs text-on-surface-variant line-clamp-2 mb-3">
|
||||||
|
{ride.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="text-xs font-mono text-on-surface-variant space-y-1 mb-3">
|
||||||
|
<div className="flex items-center gap-1.5 truncate">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-secondary-fixed">
|
||||||
|
location_on
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{ride.region}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 truncate">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-primary-container">
|
||||||
|
flag
|
||||||
|
</span>
|
||||||
|
<span className="truncate">Rally: {ride.meetingPoint}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-outline-variant/20 text-xs font-mono text-on-surface-variant">
|
||||||
|
<span>Lead: {ride.leadHandle}</span>
|
||||||
|
<span className="text-secondary-fixed">
|
||||||
|
{ride.participantCount} / {ride.participantLimit} Riders
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-space-lg pt-0">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<Link
|
||||||
|
href="/group-rides?view=manage"
|
||||||
|
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-xs font-bold transition flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">add_circle</span>
|
||||||
|
<span>View in Management Console</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/login?redirect=/group-rides"
|
||||||
|
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-xs font-bold transition flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">lock</span>
|
||||||
|
<span>Sign In to Join Pack</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Bottom Conversion Section */}
|
||||||
|
<section className="max-w-container-max mx-auto px-gutter-desktop mb-space-4xl">
|
||||||
|
<div className="relative rounded-3xl bg-gradient-to-r from-surface-container-low via-surface-container to-surface-container-high border border-outline-variant/30 p-8 sm:p-12 text-center overflow-hidden shadow-2xl">
|
||||||
|
<div className="absolute top-0 right-1/4 w-72 h-72 bg-primary-container/10 rounded-full blur-3xl pointer-events-none"></div>
|
||||||
|
<div className="relative z-10 max-w-2xl mx-auto space-y-4">
|
||||||
|
<span className="font-label-caps text-label-caps text-secondary-fixed">
|
||||||
|
Ready to Lead Formation?
|
||||||
|
</span>
|
||||||
|
<h2 className="font-display-hero text-2xl sm:text-3xl font-extrabold text-on-surface tracking-tight">
|
||||||
|
Organize, coordinate, and debrief group rides with precision telemetry.
|
||||||
|
</h2>
|
||||||
|
<p className="font-body-md text-sm sm:text-base text-on-surface-variant">
|
||||||
|
Join thousands of sport riders and touring groups who keep their squad in formation with
|
||||||
|
MotorSignal. Create your account in under 60 seconds.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="px-6 py-3 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-sm font-bold shadow-lg hover:shadow-[0_0_24px_rgba(0,242,254,0.45)] transition"
|
||||||
|
>
|
||||||
|
Create Free Account
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login?redirect=/group-rides"
|
||||||
|
className="px-6 py-3 rounded-xl bg-surface-container-highest hover:bg-surface-container-lowest text-on-surface font-headline-sm text-sm font-semibold border border-outline-variant/40 transition"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,10 @@ export interface GroupRideSummary {
|
||||||
distance: string;
|
distance: string;
|
||||||
meetingPoint: string;
|
meetingPoint: string;
|
||||||
userStatus?: 'none' | 'requested' | 'joined' | 'waitlisted';
|
userStatus?: 'none' | 'requested' | 'joined' | 'waitlisted';
|
||||||
|
isOrganizer?: boolean;
|
||||||
|
status?: 'scheduled' | 'assembling' | 'underway' | 'completed' | 'canceled';
|
||||||
|
rideCode?: string;
|
||||||
|
clubName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRideFormData {
|
export interface CreateRideFormData {
|
||||||
|
|
@ -53,3 +57,19 @@ export interface CreateRideFormData {
|
||||||
meetingPoint: string;
|
meetingPoint: string;
|
||||||
participantLimit: number;
|
participantLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditRideFormData {
|
||||||
|
title: string;
|
||||||
|
region: string;
|
||||||
|
description: string;
|
||||||
|
paceClassification: GroupRideSummary['paceClassification'];
|
||||||
|
formationProfile: string;
|
||||||
|
joinPolicy: GroupRideSummary['joinPolicy'];
|
||||||
|
scheduledDate: string;
|
||||||
|
scheduledTime: string;
|
||||||
|
distance: string;
|
||||||
|
meetingPoint: string;
|
||||||
|
participantLimit: number;
|
||||||
|
status: 'scheduled' | 'assembling' | 'underway' | 'completed' | 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import React from 'react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import GroupRidesPage from '../app/group-rides/page';
|
import GroupRidesPage from '../app/group-rides/page';
|
||||||
|
import { GroupRidesShowcase } from '../components/group-rides/group-rides-showcase';
|
||||||
|
import { GroupRidesManagement } from '../components/group-rides/group-rides-management';
|
||||||
import { LivePackRadar } from '../components/group-rides/live-pack-radar';
|
import { LivePackRadar } from '../components/group-rides/live-pack-radar';
|
||||||
import { JoinRideModal } from '../components/group-rides/join-ride-modal';
|
import { JoinRideModal } from '../components/group-rides/join-ride-modal';
|
||||||
import { CreateRideModal } from '../components/group-rides/create-ride-modal';
|
import { CreateRideModal } from '../components/group-rides/create-ride-modal';
|
||||||
|
|
@ -264,42 +266,99 @@ describe('Group Rides Interactive Page & Components', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 5. Full GroupRidesPage Integrated Flow
|
// 5. Separate Pages: Showcase (Logged Out) vs Management (Logged In)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
describe('Full GroupRidesPage Integration', () => {
|
describe('GroupRidesShowcase (For Not Logged In Users)', () => {
|
||||||
it('coordinates create ride and join ride modals and alerts', async () => {
|
it('renders hero, radar cockpit, 6-feature grid, and conversion CTAs', () => {
|
||||||
render(<GroupRidesPage />);
|
render(<GroupRidesShowcase isLoggedIn={false} />);
|
||||||
|
|
||||||
// Hero check
|
// Hero check
|
||||||
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
||||||
expect(screen.getByText(/Never lose a wingman/i)).toBeDefined();
|
expect(screen.getByText(/Never lose a wingman/i)).toBeDefined();
|
||||||
|
|
||||||
// Open Create Modal from hero button
|
// Feature highlights check
|
||||||
const createHeroBtn = screen.getByRole('button', { name: /create group ride/i });
|
expect(screen.getByText(/Staggered Paceline Warnings/i)).toBeDefined();
|
||||||
fireEvent.click(createHeroBtn);
|
expect(screen.getByText(/Crash & Separation Alerts/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Peer-to-Peer Mesh Sync/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/3D Multi-Bike Replay/i)).toBeDefined();
|
||||||
|
|
||||||
|
// CTAs check
|
||||||
|
expect(screen.getByRole('link', { name: /get started free/i })).toBeDefined();
|
||||||
|
expect(screen.getByRole('link', { name: /sign in to join runs/i })).toBeDefined();
|
||||||
|
|
||||||
|
// Community preview
|
||||||
|
expect(screen.getByText(/Sample Pack Formations/i)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GroupRidesManagement (For Logged In Users - View, Add, Remove, Alter)', () => {
|
||||||
|
it('allows viewing, adding, altering, canceling, and deleting group rides', async () => {
|
||||||
|
const mockUser = { email: 'lead.rider@motorsignal.app', displayName: 'LeadRider' };
|
||||||
|
render(<GroupRidesManagement currentUser={mockUser} />);
|
||||||
|
|
||||||
|
// Header & Console title
|
||||||
|
expect(screen.getByText(/Group Rides Management/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Squad Management & Telemetry Hub/i)).toBeDefined();
|
||||||
|
|
||||||
|
// 1. VIEW: Verify initial organized and joined rides
|
||||||
|
expect(screen.getByText(/Bay Area Dawn Patrol/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Road Captain \(Organizer\)/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Malibu Canyons Fast Pace/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Squad Wingman/i)).toBeDefined();
|
||||||
|
|
||||||
|
// 2. ADD: Create a new group ride
|
||||||
|
const createBtn = screen.getByRole('button', { name: /create group ride/i });
|
||||||
|
fireEvent.click(createBtn);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /create group ride run/i })).toBeDefined();
|
expect(screen.getByRole('heading', { name: /create group ride run/i })).toBeDefined();
|
||||||
|
|
||||||
// Fill and submit
|
|
||||||
fireEvent.change(screen.getByLabelText(/ride title/i), {
|
fireEvent.change(screen.getByLabelText(/ride title/i), {
|
||||||
target: { value: 'Redwood Highway Sprint' },
|
target: { value: 'High Sierra Alpine Sprint' },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/region \/ city/i), {
|
fireEvent.change(screen.getByLabelText(/region \/ city/i), {
|
||||||
target: { value: 'Eureka, CA' },
|
target: { value: 'Mammoth Lakes, CA' },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
|
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
|
||||||
|
|
||||||
// Directory should now show the new ride at the top
|
// Verify newly created ride appears in the list
|
||||||
expect(screen.getByRole('heading', { name: /Redwood Highway Sprint/i })).toBeDefined();
|
expect(screen.getByRole('heading', { name: /High Sierra Alpine Sprint/i })).toBeDefined();
|
||||||
expect(screen.getByText(/Eureka, CA/i)).toBeDefined();
|
|
||||||
|
|
||||||
// Open Join Modal from hero button
|
// 3. ALTER: Edit existing organized ride parameters
|
||||||
const joinHeroBtn = screen.getByRole('button', { name: /join with ride code \/ qr/i });
|
const alterButtons = screen.getAllByRole('button', { name: /alter \/ edit/i });
|
||||||
fireEvent.click(joinHeroBtn);
|
fireEvent.click(alterButtons[0]);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: /alter group ride parameters/i })).toBeDefined();
|
||||||
|
const editTitleInput = screen.getByLabelText(/ride title \*/i);
|
||||||
|
fireEvent.change(editTitleInput, { target: { value: 'High Sierra Dawn Express' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: /High Sierra Dawn Express/i })).toBeDefined();
|
||||||
|
|
||||||
|
// 4. CANCEL: Mark ride as canceled
|
||||||
|
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
|
||||||
|
// Click cancel on the first active ride
|
||||||
|
fireEvent.click(cancelButtons[0]);
|
||||||
|
expect(screen.getAllByText(/Canceled/i).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 5. REMOVE / DELETE: Delete organized ride with confirmation modal
|
||||||
|
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
||||||
|
fireEvent.click(deleteButtons[0]);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: /delete group ride/i })).toBeDefined();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /delete ride/i }));
|
||||||
|
|
||||||
|
// 6. LEAVE: Leave joined pack run with confirmation modal
|
||||||
|
const leaveBtn = screen.getByRole('button', { name: /leave pack/i });
|
||||||
|
fireEvent.click(leaveBtn);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: /leave group ride/i })).toBeDefined();
|
||||||
|
const leaveConfirmBtns = screen.getAllByRole('button', { name: /leave pack/i });
|
||||||
|
fireEvent.click(leaveConfirmBtns[leaveConfirmBtns.length - 1]);
|
||||||
|
|
||||||
|
// 7. JOIN WITH CODE:
|
||||||
|
const joinBtn = screen.getByRole('button', { name: /join with code/i });
|
||||||
|
fireEvent.click(joinBtn);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /join pack formation/i })).toBeDefined();
|
expect(screen.getByRole('heading', { name: /join pack formation/i })).toBeDefined();
|
||||||
|
|
||||||
// Enter code and submit
|
|
||||||
const codeInput = screen.getByLabelText(/ride code/i);
|
const codeInput = screen.getByLabelText(/ride code/i);
|
||||||
fireEvent.change(codeInput, { target: { value: 'APEX84' } });
|
fireEvent.change(codeInput, { target: { value: 'APEX84' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /connect squad telemetry/i }));
|
fireEvent.click(screen.getByRole('button', { name: /connect squad telemetry/i }));
|
||||||
|
|
@ -307,6 +366,21 @@ describe('Group Rides Interactive Page & Components', () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/Telemetry Linked/i)).toBeDefined();
|
expect(screen.getByText(/Telemetry Linked/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 8. RADAR TAB:
|
||||||
|
const radarTabBtn = screen.getByRole('button', { name: /live squad radar cockpit/i });
|
||||||
|
fireEvent.click(radarTabBtn);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Active Squad Telemetry Stream/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Swarm Recon: Skyline Blvd > Coast Hwy/i)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Full GroupRidesPage Smart Routing', () => {
|
||||||
|
it('renders feature showcase for unauthenticated users', () => {
|
||||||
|
render(<GroupRidesPage />);
|
||||||
|
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Never lose a wingman/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue