2119 lines
100 KiB
TypeScript
2119 lines
100 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useMemo, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { getApps, initializeApp } from 'firebase/app';
|
|
import { getAuth, connectAuthEmulator } from 'firebase/auth';
|
|
import {
|
|
collection,
|
|
query,
|
|
where,
|
|
onSnapshot,
|
|
doc,
|
|
writeBatch,
|
|
updateDoc,
|
|
deleteDoc,
|
|
setDoc,
|
|
arrayUnion,
|
|
arrayRemove,
|
|
increment,
|
|
serverTimestamp,
|
|
getDoc,
|
|
getFirestore,
|
|
connectFirestoreEmulator,
|
|
} from 'firebase/firestore';
|
|
import { firestore } from '../../lib/firebase-client';
|
|
import { buildWebGroupRidePayload } from '../../lib/group-ride-payload';
|
|
|
|
function getActiveFirestore() {
|
|
if (
|
|
typeof window !== 'undefined' &&
|
|
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
|
) {
|
|
let qaApp = getApps().find((a) => a.name === 'motorsignal-local-qa');
|
|
if (!qaApp) {
|
|
try {
|
|
qaApp = initializeApp(
|
|
{ apiKey: 'demo-key', projectId: 'demo-motorsignal-qa', authDomain: 'localhost' },
|
|
'motorsignal-local-qa'
|
|
);
|
|
const db = getFirestore(qaApp);
|
|
connectFirestoreEmulator(db, '127.0.0.1', 8088);
|
|
const auth = getAuth(qaApp);
|
|
connectAuthEmulator(auth, 'http://127.0.0.1:9099');
|
|
return db;
|
|
} catch (_) {}
|
|
}
|
|
if (qaApp) {
|
|
try {
|
|
return getFirestore(qaApp);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
return firestore;
|
|
}
|
|
import { INITIAL_GROUP_RIDES } from './fixtures';
|
|
import { GroupRideSummary } from './types';
|
|
import { EditRideModal } from './edit-ride-modal';
|
|
import { DeleteConfirmModal } from './delete-confirm-modal';
|
|
import { JoinRideModal } from './join-ride-modal';
|
|
import { LivePackRadar } from './live-pack-radar';
|
|
import { RideDetailsModal } from './ride-details-modal';
|
|
|
|
interface GroupRidesManagementProps {
|
|
currentUser?: { uid?: string; email?: string | null; displayName?: string | null; getIdToken?: () => Promise<string> } | null;
|
|
onViewShowcase?: () => void;
|
|
}
|
|
|
|
function mapFirestoreDocToSummary(
|
|
data: any,
|
|
docId: string,
|
|
currentUid?: string
|
|
): GroupRideSummary {
|
|
const isOrganizer = Boolean(currentUid && data.organizerUid === currentUid);
|
|
const isParticipant = Boolean(
|
|
currentUid && Array.isArray(data.accessUids) && data.accessUids.includes(currentUid)
|
|
);
|
|
|
|
let scheduledDate = 'Upcoming';
|
|
let scheduledTime = 'TBD';
|
|
if (data.scheduledStartAt) {
|
|
try {
|
|
const d = new Date(data.scheduledStartAt);
|
|
if (!isNaN(d.getTime())) {
|
|
scheduledDate = d.toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
scheduledTime = d.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
let distance = 'Open Route';
|
|
if (typeof data.routeDistanceMeters === 'number' && data.routeDistanceMeters > 0) {
|
|
distance = `${(data.routeDistanceMeters / 1609.34).toFixed(1)} Miles`;
|
|
} else if (data.distance) {
|
|
distance = String(data.distance);
|
|
}
|
|
|
|
const meetingPoint =
|
|
data.meetingPoint?.displayName ||
|
|
data.meetingPoint?.address ||
|
|
'Designated Rally Point';
|
|
const region =
|
|
data.meetingPoint?.address ||
|
|
data.meetingPoint?.displayName ||
|
|
data.region ||
|
|
'Regional Run';
|
|
|
|
let routeSnippet = data.routeTitle;
|
|
if (!routeSnippet && data.destination?.displayName) {
|
|
routeSnippet = `${meetingPoint} > ${data.destination.displayName}`;
|
|
}
|
|
if (!routeSnippet) {
|
|
routeSnippet = data.routeSnippet || 'Custom pack route staged on MotorSignal';
|
|
}
|
|
|
|
const leadHandle =
|
|
data.organizerDisplayName ||
|
|
data.organizerHandle ||
|
|
(isOrganizer ? 'You (Organizer)' : 'Pack Marshal');
|
|
const leadAvatarText = (
|
|
data.organizerDisplayName?.[0] ||
|
|
data.organizerHandle?.[0] ||
|
|
(isOrganizer ? 'Y' : 'P')
|
|
).toUpperCase();
|
|
|
|
const status = data.status || 'scheduled';
|
|
|
|
let userStatus: GroupRideSummary['userStatus'] = 'none';
|
|
if (isOrganizer || isParticipant) {
|
|
userStatus = 'joined';
|
|
}
|
|
|
|
return {
|
|
id: docId,
|
|
title: data.title || 'Untitled Ride',
|
|
description: data.description || '',
|
|
region,
|
|
scheduledDate,
|
|
scheduledTime,
|
|
paceClassification: data.paceClassification || 'Brisk Pace',
|
|
discipline: data.discipline || 'Sport / Canyon',
|
|
formationProfile: data.formationProfile || 'Staggered Dual Column [B]',
|
|
joinPolicy: data.joinPolicy || 'requestToJoin',
|
|
leadHandle,
|
|
leadAvatarText,
|
|
participantCount:
|
|
typeof data.participantCount === 'number' ? data.participantCount : 1,
|
|
participantLimit:
|
|
typeof data.participantLimit === 'number' ? data.participantLimit : 12,
|
|
distance,
|
|
meetingPoint,
|
|
routeSnippet,
|
|
userStatus,
|
|
isOrganizer,
|
|
status,
|
|
rideCode: data.id || docId,
|
|
clubName: data.clubName || undefined,
|
|
accessUids: Array.isArray(data.accessUids) ? data.accessUids : (data.organizerUid ? [data.organizerUid] : []),
|
|
organizerUid: data.organizerUid,
|
|
meetingPointCoords: data.meetingPoint?.latitude && data.meetingPoint?.longitude
|
|
? { lat: Number(data.meetingPoint.latitude), lon: Number(data.meetingPoint.longitude) }
|
|
: undefined,
|
|
} as any;
|
|
}
|
|
|
|
type ManagementTab = 'my-rides' | 'discover' | 'radar';
|
|
type MyRidesFilter = 'all' | 'organized' | 'joined';
|
|
|
|
/**
|
|
* Redesigned Create Group Ride Modal
|
|
* Features:
|
|
* - Plain, clear labels and helpful hints
|
|
* - Sensible defaults: Current date/time + 1 hour, standard pace, paved surface, 12 riders, moderated join
|
|
* - Explicit Review Step before final submission
|
|
* - Preserves personal vs club ride distinctions, visibility settings, join policies, and authorization rules
|
|
*/
|
|
interface CreateRideModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onCreateRide: (ride: GroupRideSummary) => void;
|
|
}
|
|
|
|
function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) {
|
|
// Sensible default date/time calculation: current time + 1 hour
|
|
const defaultDateValues = useMemo(() => {
|
|
const oneHourAhead = new Date(Date.now() + 60 * 60 * 1000);
|
|
const dateStr = oneHourAhead.toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
const timeStr = oneHourAhead.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
});
|
|
return { dateStr, timeStr };
|
|
}, [isOpen]);
|
|
|
|
const [activeStep, setActiveStep] = useState<'details' | 'review'>('details');
|
|
const [title, setTitle] = useState('');
|
|
const [region, setRegion] = useState('');
|
|
const [scheduledDate, setScheduledDate] = useState(defaultDateValues.dateStr);
|
|
const [scheduledTime, setScheduledTime] = useState(defaultDateValues.timeStr);
|
|
const [surfaceType, setSurfaceType] = useState('Paved Asphalt');
|
|
const [paceClassification, setPaceClassification] = useState<GroupRideSummary['paceClassification']>('Brisk Pace');
|
|
const [discipline, setDiscipline] = useState<NonNullable<GroupRideSummary['discipline']>>('Sport / Canyon');
|
|
const [rideType, setRideType] = useState<'personal' | 'club'>('personal');
|
|
const [clubName, setClubName] = useState('');
|
|
const [joinPolicy, setJoinPolicy] = useState<GroupRideSummary['joinPolicy']>('requestToJoin');
|
|
const [visibility, setVisibility] = useState<'public' | 'unlisted' | 'private'>('public');
|
|
const [formationProfile, setFormationProfile] = useState('Staggered Dual Column [B]');
|
|
const [distance, setDistance] = useState('45.0 Miles');
|
|
const [meetingPoint, setMeetingPoint] = useState('');
|
|
const [routeSnippet, setRouteSnippet] = useState('Skyline Blvd > Stage Rd > PCH Pacific Coast Hwy');
|
|
const [participantLimit, setParticipantLimit] = useState(12);
|
|
const [description, setDescription] = useState('');
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
|
|
// Reset form when modal opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setActiveStep('details');
|
|
setTitle('');
|
|
setRegion('');
|
|
setScheduledDate(defaultDateValues.dateStr);
|
|
setScheduledTime(defaultDateValues.timeStr);
|
|
setSurfaceType('Paved Asphalt');
|
|
setPaceClassification('Brisk Pace');
|
|
setDiscipline('Sport / Canyon');
|
|
setRideType('personal');
|
|
setClubName('');
|
|
setJoinPolicy('requestToJoin');
|
|
setVisibility('public');
|
|
setFormationProfile('Staggered Dual Column [B]');
|
|
setDistance('45.0 Miles');
|
|
setMeetingPoint('');
|
|
setRouteSnippet('Skyline Blvd > Stage Rd > PCH Pacific Coast Hwy');
|
|
setParticipantLimit(12);
|
|
setDescription('');
|
|
setErrorMsg(null);
|
|
}
|
|
}, [isOpen, defaultDateValues]);
|
|
|
|
// Handle ESC key press
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isOpen) {
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const validate = (): boolean => {
|
|
if (!title.trim()) {
|
|
setErrorMsg('Please enter a ride title.');
|
|
setActiveStep('details');
|
|
return false;
|
|
}
|
|
if (!region.trim()) {
|
|
setErrorMsg('Please enter a location or region.');
|
|
setActiveStep('details');
|
|
return false;
|
|
}
|
|
if (participantLimit < 2) {
|
|
setErrorMsg('Participant limit must be at least 2 riders.');
|
|
setActiveStep('details');
|
|
return false;
|
|
}
|
|
setErrorMsg(null);
|
|
return true;
|
|
};
|
|
|
|
const handleProceedToReview = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
if (validate()) {
|
|
setActiveStep('review');
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!validate()) return;
|
|
|
|
const newRide: GroupRideSummary & {
|
|
visibility?: 'public' | 'unlisted' | 'private';
|
|
surfaceType?: string;
|
|
clubId?: string;
|
|
} = {
|
|
id: `ride-${Date.now()}`,
|
|
title: title.trim(),
|
|
description: description.trim() || 'Custom squadron pack run with MotorSignal live telemetry.',
|
|
region: region.trim(),
|
|
scheduledDate,
|
|
scheduledTime,
|
|
paceClassification,
|
|
discipline,
|
|
formationProfile,
|
|
joinPolicy,
|
|
leadHandle: 'You (Organizer)',
|
|
leadAvatarText: 'YOU',
|
|
participantCount: 1,
|
|
participantLimit: Number(participantLimit) || 12,
|
|
distance: distance.trim() || '45.0 Miles',
|
|
meetingPoint: meetingPoint.trim() || 'Designated Rally Staging Point',
|
|
routeSnippet: routeSnippet.trim() || 'Custom GPX route uploaded by organizer.',
|
|
userStatus: 'joined',
|
|
status: 'scheduled',
|
|
visibility,
|
|
surfaceType,
|
|
...(rideType === 'club' && clubName.trim() ? { clubName: clubName.trim(), clubId: `club-${clubName.toLowerCase().replace(/\s+/g, '-')}` } : {}),
|
|
};
|
|
|
|
onCreateRide(newRide);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="create-ride-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-space-md bg-surface-container-lowest/80 backdrop-blur-md overflow-y-auto"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div className="bg-surface-container-low max-w-2xl w-full rounded-2xl p-space-xl shadow-2xl relative border border-outline-variant/30 flex flex-col my-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-space-md pb-2 border-b border-outline-variant/20">
|
|
<div className="flex items-center gap-space-xs">
|
|
<span className="material-symbols-outlined text-primary-container text-[24px]">
|
|
add_circle
|
|
</span>
|
|
<h3 id="create-ride-title" className="font-headline-md text-headline-md text-on-surface">
|
|
Create Group Ride Run
|
|
</h3>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-on-surface-variant hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors cursor-pointer"
|
|
aria-label="Close modal"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Step Tabs Indicator */}
|
|
<div className="flex items-center gap-2 mb-space-md">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveStep('details')}
|
|
className={`flex-1 py-2 px-3 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition ${
|
|
activeStep === 'details'
|
|
? 'bg-primary-container text-on-primary-container font-bold shadow-sm'
|
|
: 'bg-surface-container text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
<span className="w-5 h-5 rounded-full bg-surface-container-lowest flex items-center justify-center text-[11px] font-bold">1</span>
|
|
<span>Ride Configuration</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleProceedToReview}
|
|
className={`flex-1 py-2 px-3 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition ${
|
|
activeStep === 'review'
|
|
? 'bg-primary-container text-on-primary-container font-bold shadow-sm'
|
|
: 'bg-surface-container text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
<span className="w-5 h-5 rounded-full bg-surface-container-lowest flex items-center justify-center text-[11px] font-bold">2</span>
|
|
<span>Pre-Flight Review</span>
|
|
</button>
|
|
</div>
|
|
|
|
{errorMsg && (
|
|
<div className="mb-space-md p-space-sm rounded-lg bg-error-container text-on-error-container font-code-mono text-code-mono flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[18px]">error</span>
|
|
<span>{errorMsg}</span>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-space-md">
|
|
{activeStep === 'details' && (
|
|
<>
|
|
{/* Ride Title */}
|
|
<div>
|
|
<label htmlFor="ride-title-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Ride Title *
|
|
</label>
|
|
<input
|
|
id="ride-title-input"
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="e.g. Sierra Foothills Canyon Run"
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Region & Distance Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
|
<div>
|
|
<label htmlFor="ride-region-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Region / City *
|
|
</label>
|
|
<input
|
|
id="ride-region-input"
|
|
type="text"
|
|
value={region}
|
|
onChange={(e) => setRegion(e.target.value)}
|
|
placeholder="e.g. Sacramento, CA"
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="ride-distance-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Route Distance
|
|
</label>
|
|
<input
|
|
id="ride-distance-input"
|
|
type="text"
|
|
value={distance}
|
|
onChange={(e) => setDistance(e.target.value)}
|
|
placeholder="e.g. 45.0 Miles"
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Date & Time Grid (Defaults to current time + 1 hour) */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
|
<div>
|
|
<label htmlFor="ride-date-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Scheduled Day
|
|
</label>
|
|
<input
|
|
id="ride-date-input"
|
|
type="text"
|
|
value={scheduledDate}
|
|
onChange={(e) => setScheduledDate(e.target.value)}
|
|
placeholder="e.g. Saturday"
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
/>
|
|
<span className="text-[11px] font-mono text-on-surface-variant mt-0.5 block">
|
|
Defaults to upcoming run date
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="ride-time-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Start Time
|
|
</label>
|
|
<input
|
|
id="ride-time-input"
|
|
type="text"
|
|
value={scheduledTime}
|
|
onChange={(e) => setScheduledTime(e.target.value)}
|
|
placeholder="e.g. 09:30 AM PST"
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
/>
|
|
<span className="text-[11px] font-mono text-primary-container mt-0.5 block">
|
|
Defaulted to 1 hour from now
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Surface & Pace Classification */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
|
<div>
|
|
<label htmlFor="ride-surface-select" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Road Surface
|
|
</label>
|
|
<select
|
|
id="ride-surface-select"
|
|
value={surfaceType}
|
|
onChange={(e) => setSurfaceType(e.target.value)}
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
>
|
|
<option value="Paved Asphalt">Paved Asphalt (Recommended)</option>
|
|
<option value="Mixed Surface">Mixed Surface / ADV</option>
|
|
<option value="Mountain Pass">Mountain Pass / Technical</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="ride-pace-select" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Pace Classification
|
|
</label>
|
|
<select
|
|
id="ride-pace-select"
|
|
value={paceClassification}
|
|
onChange={(e) => setPaceClassification(e.target.value as any)}
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
>
|
|
<option value="Brisk Pace">Brisk Pace (Standard)</option>
|
|
<option value="Casual">Casual / Relaxed</option>
|
|
<option value="Spirited">Spirited / Fast</option>
|
|
<option value="Track Pace">Track Pace</option>
|
|
<option value="Endurance / Chill">Endurance / Chill</option>
|
|
<option value="Weekend Tours">Weekend Tours</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ride Classification (Personal vs Club) & Privacy/Join Policy */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
|
<div>
|
|
<label htmlFor="ride-type-select" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Ride Classification
|
|
</label>
|
|
<select
|
|
id="ride-type-select"
|
|
value={rideType}
|
|
onChange={(e) => setRideType(e.target.value as 'personal' | 'club')}
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
>
|
|
<option value="personal">Personal Ride (Public Discovery)</option>
|
|
<option value="club">Club-Affiliated Run</option>
|
|
</select>
|
|
{rideType === 'club' && (
|
|
<input
|
|
type="text"
|
|
placeholder="Club Name (e.g. Apex Guild)"
|
|
value={clubName}
|
|
onChange={(e) => setClubName(e.target.value)}
|
|
className="mt-2 w-full bg-surface-container-lowest text-xs text-on-surface px-3 py-1.5 rounded-lg border border-outline-variant/30"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label htmlFor="ride-privacy-select" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Pack Privacy & Access
|
|
</label>
|
|
<select
|
|
id="ride-privacy-select"
|
|
value={joinPolicy}
|
|
onChange={(e) => setJoinPolicy(e.target.value as any)}
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
>
|
|
<option value="requestToJoin">Moderated (Request Slot Required)</option>
|
|
<option value="open">Public (Open to All Riders)</option>
|
|
<option value="inviteOnly">Private (Invite / Code Only)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rider Limit & Discipline */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
|
<div>
|
|
<label htmlFor="ride-limit-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Rider Limit
|
|
</label>
|
|
<input
|
|
id="ride-limit-input"
|
|
type="number"
|
|
min={2}
|
|
max={50}
|
|
value={participantLimit}
|
|
onChange={(e) => setParticipantLimit(Number(e.target.value))}
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="ride-discipline-select" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Discipline / Style
|
|
</label>
|
|
<select
|
|
id="ride-discipline-select"
|
|
value={discipline}
|
|
onChange={(e) => setDiscipline(e.target.value as any)}
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
>
|
|
<option value="Sport / Canyon">Sport / Canyon</option>
|
|
<option value="Touring / ADV">Touring / ADV</option>
|
|
<option value="Track / Circuit">Track / Circuit</option>
|
|
<option value="Cruiser / Pack">Cruiser / Pack</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meeting Point */}
|
|
<div>
|
|
<label htmlFor="ride-meeting-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Meeting Point / Staging Location
|
|
</label>
|
|
<input
|
|
id="ride-meeting-input"
|
|
type="text"
|
|
value={meetingPoint}
|
|
onChange={(e) => setMeetingPoint(e.target.value)}
|
|
placeholder="e.g. Chevron Staging Lot at Highway 49"
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2.5 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label htmlFor="ride-desc-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
|
Description & Safety Briefing
|
|
</label>
|
|
<textarea
|
|
id="ride-desc-input"
|
|
rows={2}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Formation rules, fuel stop locations, radio channels..."
|
|
className="w-full bg-surface-container-lowest font-body-md text-body-md text-on-surface px-space-md py-2 rounded-xl border border-outline-variant/30 focus:border-primary-container outline-none resize-none"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{activeStep === 'review' && (
|
|
<div className="space-y-4">
|
|
<div className="p-4 rounded-xl bg-surface-container border border-primary-container/30 space-y-3">
|
|
<div className="flex items-center gap-2 text-primary-container font-mono text-xs font-bold">
|
|
<span className="material-symbols-outlined text-[18px]">verified</span>
|
|
<span>Pre-Flight Ride Review</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-xs font-mono">
|
|
<div className="p-2.5 rounded-lg bg-surface-container-low border border-outline-variant/20">
|
|
<span className="text-on-surface-variant block text-[10px]">Ride Title</span>
|
|
<span className="font-bold text-on-surface">{title || 'Untitled Ride'}</span>
|
|
</div>
|
|
<div className="p-2.5 rounded-lg bg-surface-container-low border border-outline-variant/20">
|
|
<span className="text-on-surface-variant block text-[10px]">Region / Destination</span>
|
|
<span className="font-bold text-on-surface">{region || 'TBD'}</span>
|
|
</div>
|
|
<div className="p-2.5 rounded-lg bg-surface-container-low border border-outline-variant/20">
|
|
<span className="text-on-surface-variant block text-[10px]">Scheduled Start</span>
|
|
<span className="font-bold text-primary-container">{scheduledDate} • {scheduledTime}</span>
|
|
</div>
|
|
<div className="p-2.5 rounded-lg bg-surface-container-low border border-outline-variant/20">
|
|
<span className="text-on-surface-variant block text-[10px]">Pace & Surface</span>
|
|
<span className="font-bold text-secondary-fixed">{paceClassification} • {surfaceType}</span>
|
|
</div>
|
|
<div className="p-2.5 rounded-lg bg-surface-container-low border border-outline-variant/20">
|
|
<span className="text-on-surface-variant block text-[10px]">Classification</span>
|
|
<span className="font-bold text-on-surface">
|
|
{rideType === 'club' ? `Club Ride (${clubName || 'Club'})` : 'Personal Community Ride'}
|
|
</span>
|
|
</div>
|
|
<div className="p-2.5 rounded-lg bg-surface-container-low border border-outline-variant/20">
|
|
<span className="text-on-surface-variant block text-[10px]">Join Policy</span>
|
|
<span className="font-bold text-on-surface">
|
|
{joinPolicy === 'open' ? 'Open Access' : joinPolicy === 'requestToJoin' ? 'Approval Required' : 'Invite Only'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-2.5 rounded-lg bg-surface-container-low border border-outline-variant/20 text-xs font-mono space-y-1">
|
|
<div className="flex items-center gap-1.5 text-on-surface">
|
|
<span className="material-symbols-outlined text-[14px] text-secondary-fixed">flag</span>
|
|
<span>Rally Staging: {meetingPoint || 'Designated Rally Staging Point'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-on-surface-variant">
|
|
<span className="material-symbols-outlined text-[14px] text-primary-container">groups</span>
|
|
<span>Capacity: Max {participantLimit} Riders</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-secondary-fixed">
|
|
<span className="material-symbols-outlined text-[14px]">shield</span>
|
|
<span>Telemetry Mesh: 20Hz Sub-Second GNSS • Staggered Dual Column</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center justify-between gap-space-sm pt-space-sm border-t border-outline-variant/20">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-space-lg py-2.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-xs sm:text-sm transition-colors cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{activeStep === 'review' ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveStep('details')}
|
|
className="px-4 py-2.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-xs sm:text-sm transition-colors cursor-pointer"
|
|
>
|
|
Back to Edit
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleProceedToReview}
|
|
className="px-4 py-2.5 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"
|
|
>
|
|
Review Details
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
id="btn-publish-ride"
|
|
className="px-space-xl py-2.5 rounded-xl bg-primary-container hover:bg-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.4)] transition-all cursor-pointer"
|
|
>
|
|
Publish Group Ride
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GroupRidesManagement({
|
|
currentUser,
|
|
onViewShowcase,
|
|
}: GroupRidesManagementProps) {
|
|
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
|
|
const [communityRides, setCommunityRides] = useState<GroupRideSummary[]>([]);
|
|
const [isCommunityLoading, setIsCommunityLoading] = useState(false);
|
|
const [communityError, setCommunityError] = useState<string | null>(null);
|
|
|
|
const [activeTab, setActiveTab] = useState<ManagementTab>('my-rides');
|
|
const [myRidesFilter, setMyRidesFilter] = useState<MyRidesFilter>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [paceFilter, setPaceFilter] = useState<string>('all');
|
|
|
|
// Selected Radar Ride (CA-84-SW default or user ride)
|
|
const [selectedRadarRideId, setSelectedRadarRideId] = useState<string>('session-ca-84-sw');
|
|
|
|
// 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);
|
|
const [selectedDetailRide, setSelectedDetailRide] = 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]
|
|
);
|
|
|
|
// Selected radar ride object
|
|
const [urlLoadedRide, setUrlLoadedRide] = useState<GroupRideSummary | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (selectedRadarRideId && selectedRadarRideId !== 'session-ca-84-sw') {
|
|
const found = rides.find((r) => r.id === selectedRadarRideId) ||
|
|
communityRides.find((r) => r.id === selectedRadarRideId);
|
|
if (found) {
|
|
setUrlLoadedRide(null);
|
|
return;
|
|
}
|
|
const db = getActiveFirestore();
|
|
if (db) {
|
|
getDoc(doc(db, 'group_rides', selectedRadarRideId)).then((snap) => {
|
|
if (snap.exists()) {
|
|
setUrlLoadedRide(mapFirestoreDocToSummary(snap.data(), snap.id, currentUser?.uid));
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
}, [selectedRadarRideId, rides, communityRides, currentUser?.uid]);
|
|
|
|
const selectedRadarRide = useMemo(() => {
|
|
if (selectedRadarRideId === 'session-ca-84-sw') return null;
|
|
return (
|
|
rides.find((r) => r.id === selectedRadarRideId) ||
|
|
communityRides.find((r) => r.id === selectedRadarRideId) ||
|
|
(urlLoadedRide?.id === selectedRadarRideId ? urlLoadedRide : null) ||
|
|
mapFirestoreDocToSummary({ title: 'Group Ride', participantCount: 0 }, selectedRadarRideId, currentUser?.uid)
|
|
);
|
|
}, [rides, communityRides, urlLoadedRide, selectedRadarRideId, currentUser?.uid]);
|
|
|
|
// Read initial tab and rideId from URL search params if present
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const tabParam = params.get('tab');
|
|
if (tabParam === 'radar' || tabParam === 'discover' || tabParam === 'my-rides') {
|
|
setActiveTab(tabParam as ManagementTab);
|
|
}
|
|
const rideParam = params.get('rideId') || params.get('ride');
|
|
if (rideParam) {
|
|
setSelectedRadarRideId(rideParam);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// 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 = communityRides;
|
|
if (paceFilter !== 'all') {
|
|
const pf = paceFilter.toLowerCase();
|
|
list = list.filter((r) => {
|
|
const pace = r.paceClassification.toLowerCase();
|
|
if (pf === 'casual') {
|
|
return pace.includes('casual') || pace.includes('tour') || pace.includes('endurance') || pace.includes('chill');
|
|
}
|
|
return pace.includes(pf);
|
|
});
|
|
}
|
|
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) ||
|
|
(r.discipline && r.discipline.toLowerCase().includes(q))
|
|
);
|
|
}
|
|
return list;
|
|
}, [communityRides, paceFilter, searchQuery]);
|
|
|
|
// Load Community Rides via Authoritative Backend Discovery
|
|
const loadCommunityRides = async () => {
|
|
setIsCommunityLoading(true);
|
|
setCommunityError(null);
|
|
|
|
// If user has no getIdToken (e.g. unauthenticated preview or test mock without token), finish gracefully
|
|
if (!currentUser?.getIdToken) {
|
|
setIsCommunityLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = await currentUser.getIdToken();
|
|
const response = await fetch('/api/group-rides?pageSize=50', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errPayload = await response.json().catch(() => ({}));
|
|
throw new Error(errPayload.error || `HTTP ${response.status}: Could not load community rides.`);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
if (Array.isArray(payload.rides)) {
|
|
setCommunityRides(payload.rides.map((ride: any) => {
|
|
const date = new Date(ride.scheduledStartAt);
|
|
return {
|
|
...ride,
|
|
scheduledDate: !isNaN(date.getTime())
|
|
? date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
|
: 'Upcoming',
|
|
scheduledTime: !isNaN(date.getTime())
|
|
? date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
|
: 'TBD',
|
|
} as GroupRideSummary;
|
|
}));
|
|
}
|
|
} catch (error: any) {
|
|
console.warn('[GroupRidesManagement] Community rides load failed:', error);
|
|
setCommunityError('Community rides could not be loaded at this time. Please check your connection or try again.');
|
|
} finally {
|
|
setIsCommunityLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadCommunityRides();
|
|
}, [currentUser?.uid]);
|
|
|
|
// Realtime synchronization with Firestore
|
|
useEffect(() => {
|
|
const db = getActiveFirestore();
|
|
if (!currentUser?.uid || !db) {
|
|
return;
|
|
}
|
|
|
|
let isSubscribed = true;
|
|
const ridesMap = new Map<string, GroupRideSummary>();
|
|
|
|
const updateCombinedRides = () => {
|
|
if (!isSubscribed) return;
|
|
const combined = Array.from(ridesMap.values());
|
|
combined.sort((a, b) => {
|
|
if (a.status === 'canceled' && b.status !== 'canceled') return 1;
|
|
if (a.status !== 'canceled' && b.status === 'canceled') return -1;
|
|
return b.id.localeCompare(a.id);
|
|
});
|
|
setRides(combined);
|
|
};
|
|
|
|
let unsubOrganized: (() => void) | null = null;
|
|
let unsubJoined: (() => void) | null = null;
|
|
|
|
try {
|
|
const qOrganized = query(
|
|
collection(db, 'group_rides'),
|
|
where('organizerUid', '==', currentUser.uid)
|
|
);
|
|
|
|
unsubOrganized = onSnapshot(
|
|
qOrganized,
|
|
(snapshot) => {
|
|
snapshot.docs.forEach((docSnap) => {
|
|
ridesMap.set(
|
|
docSnap.id,
|
|
mapFirestoreDocToSummary(docSnap.data(), docSnap.id, currentUser.uid)
|
|
);
|
|
});
|
|
snapshot.docChanges().forEach((change) => {
|
|
if (change.type === 'removed') {
|
|
ridesMap.delete(change.doc.id);
|
|
}
|
|
});
|
|
updateCombinedRides();
|
|
},
|
|
(err) => {
|
|
console.warn('[GroupRidesManagement] Organized rides snapshot warning:', err);
|
|
}
|
|
);
|
|
|
|
const qJoined = query(
|
|
collection(db, 'group_rides'),
|
|
where('accessUids', 'array-contains', currentUser.uid)
|
|
);
|
|
|
|
unsubJoined = onSnapshot(
|
|
qJoined,
|
|
(snapshot) => {
|
|
snapshot.docs.forEach((docSnap) => {
|
|
ridesMap.set(
|
|
docSnap.id,
|
|
mapFirestoreDocToSummary(docSnap.data(), docSnap.id, currentUser.uid)
|
|
);
|
|
});
|
|
snapshot.docChanges().forEach((change) => {
|
|
if (change.type === 'removed' && ridesMap.get(change.doc.id)?.isOrganizer === false) {
|
|
ridesMap.delete(change.doc.id);
|
|
}
|
|
});
|
|
updateCombinedRides();
|
|
},
|
|
(err) => {
|
|
console.warn('[GroupRidesManagement] Joined rides snapshot warning:', err);
|
|
}
|
|
);
|
|
} catch (e) {
|
|
console.warn('[GroupRidesManagement] Subscription init warning:', e);
|
|
}
|
|
|
|
return () => {
|
|
isSubscribed = false;
|
|
if (unsubOrganized) unsubOrganized();
|
|
if (unsubJoined) unsubJoined();
|
|
};
|
|
}, [currentUser?.uid]);
|
|
|
|
// Handlers for Add, Alter, Remove, Join, Leave
|
|
const handleCreateRide = async (newRide: GroupRideSummary & { visibility?: string; clubId?: string; clubName?: string }) => {
|
|
const uid = currentUser?.uid;
|
|
const now = new Date();
|
|
const rideId = uid
|
|
? `ride_${now.getTime()}_${uid.substring(0, Math.min(6, uid.length))}`
|
|
: `local_${now.getTime()}`;
|
|
|
|
const rideWithOrganizer: GroupRideSummary = {
|
|
...newRide,
|
|
id: rideId,
|
|
isOrganizer: true,
|
|
status: 'scheduled',
|
|
userStatus: 'joined',
|
|
rideCode: rideId,
|
|
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
|
|
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
|
|
};
|
|
|
|
// Optimistic local insert — rolled back below if a backend write is attempted and fails.
|
|
setRides((prev) => [rideWithOrganizer, ...prev]);
|
|
|
|
// Without an authenticated session and Firestore we cannot persist the ride. Keep the
|
|
// local entry for responsiveness, but never claim it was saved to the cloud.
|
|
if (!uid || !firestore) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer';
|
|
const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider';
|
|
|
|
const firestoreRideData = buildWebGroupRidePayload(newRide, {
|
|
rideId,
|
|
uid,
|
|
leadHandle,
|
|
leadDisplayName,
|
|
now,
|
|
});
|
|
|
|
const organizerParticipant = {
|
|
userId: uid,
|
|
role: 'organizer',
|
|
status: 'accepted',
|
|
riderHandle: leadHandle,
|
|
riderDisplayName: leadDisplayName,
|
|
createdAt: now.toISOString(),
|
|
updatedAt: now.toISOString(),
|
|
};
|
|
|
|
const batch = writeBatch(firestore);
|
|
batch.set(doc(firestore, 'group_rides', rideId), firestoreRideData);
|
|
batch.set(doc(firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant);
|
|
await batch.commit();
|
|
|
|
// Report success only after the write actually commits.
|
|
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success');
|
|
} catch (err: any) {
|
|
console.error('Failed to persist group ride to Firestore:', err);
|
|
// Roll back the optimistic insert and report the failure truthfully.
|
|
setRides((prev) => prev.filter((r) => r.id !== rideId));
|
|
showAlert('Could not create the ride. Please check your connection and try again.', 'error');
|
|
}
|
|
};
|
|
|
|
const handleSaveEditedRide = async (updatedRide: GroupRideSummary) => {
|
|
// Optimistic local update
|
|
setRides((prev) => prev.map((r) => (r.id === updatedRide.id ? updatedRide : r)));
|
|
showAlert(`Updated parameters for "${updatedRide.title}".`, 'success');
|
|
|
|
if (currentUser?.uid && firestore) {
|
|
try {
|
|
const now = new Date().toISOString();
|
|
await updateDoc(doc(firestore, 'group_rides', updatedRide.id), {
|
|
title: updatedRide.title.trim(),
|
|
description: updatedRide.description?.trim() || '',
|
|
participantLimit: Number(updatedRide.participantLimit) || 12,
|
|
joinPolicy: updatedRide.joinPolicy || 'requestToJoin',
|
|
paceClassification: updatedRide.paceClassification || 'Brisk Pace',
|
|
discipline: updatedRide.discipline || 'Sport / Canyon',
|
|
formationProfile: updatedRide.formationProfile || 'Staggered Dual Column [B]',
|
|
routeTitle: updatedRide.routeSnippet || '',
|
|
updatedAt: now,
|
|
});
|
|
} catch (err: any) {
|
|
console.error('Failed to update group ride in Firestore:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDeleteRide = async () => {
|
|
if (!deletingRide) return;
|
|
const title = deletingRide.title;
|
|
const rideId = deletingRide.id;
|
|
setDeletingRide(null);
|
|
|
|
// Optimistic local update
|
|
setRides((prev) => prev.filter((r) => r.id !== rideId));
|
|
showAlert(`Deleted group ride "${title}". Pack telemetry cleared.`, 'info');
|
|
|
|
if (currentUser?.uid && firestore) {
|
|
try {
|
|
await deleteDoc(doc(firestore, 'group_rides', rideId));
|
|
} catch (err: any) {
|
|
console.error('Failed to delete group ride in Firestore:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleCancelRide = async (ride: GroupRideSummary) => {
|
|
// Optimistic local update
|
|
setRides((prev) =>
|
|
prev.map((r) => (r.id === ride.id ? { ...r, status: 'canceled' } : r))
|
|
);
|
|
showAlert(`Ride "${ride.title}" status changed to Canceled. Participants notified.`, 'info');
|
|
|
|
if (currentUser?.uid && firestore) {
|
|
try {
|
|
const now = new Date().toISOString();
|
|
await updateDoc(doc(firestore, 'group_rides', ride.id), {
|
|
status: 'canceled',
|
|
canceledAt: now,
|
|
canceledReason: 'Canceled by organizer via MotorSignal Web Console',
|
|
updatedAt: now,
|
|
});
|
|
} catch (err: any) {
|
|
console.error('Failed to cancel group ride in Firestore:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleLeaveRide = async () => {
|
|
if (!leavingRide) return;
|
|
const ride = leavingRide;
|
|
const uid = currentUser?.uid;
|
|
if (!uid || !firestore) {
|
|
showAlert('Sign in to leave a group ride.', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const rideRef = doc(firestore, 'group_rides', ride.id);
|
|
const participantRef = doc(rideRef, 'participants', uid);
|
|
if (ride.userStatus === 'requested') {
|
|
await updateDoc(participantRef, { status: 'left', updatedAt: serverTimestamp() });
|
|
} else {
|
|
const snapshot = await getDoc(rideRef);
|
|
const accessUids = snapshot.data()?.accessUids;
|
|
if (!Array.isArray(accessUids) || !accessUids.includes(uid)) throw new Error('Ride membership is out of date.');
|
|
const batch = writeBatch(firestore);
|
|
batch.update(participantRef, { status: 'left', updatedAt: serverTimestamp() });
|
|
batch.update(rideRef, {
|
|
accessUids: arrayRemove(uid),
|
|
participantCount: increment(-1),
|
|
updatedAt: serverTimestamp(),
|
|
});
|
|
await batch.commit();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to leave group ride:', error);
|
|
showAlert('Could not leave this ride. Refresh and try again.', 'error');
|
|
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 = async (rideId: string): Promise<'joined' | 'requested' | null> => {
|
|
const ride = [...communityRides, ...rides].find((item) => item.id === rideId);
|
|
const uid = currentUser?.uid;
|
|
if (!ride || !uid || !firestore) {
|
|
showAlert('Sign in to join a group ride.', 'error');
|
|
return null;
|
|
}
|
|
if (ride.userStatus === 'joined' || ride.userStatus === 'requested') return ride.userStatus;
|
|
if (ride.joinPolicy === 'inviteOnly') {
|
|
showAlert('This ride is invite-only. Ask the organizer for an invitation.', 'info');
|
|
return null;
|
|
}
|
|
if (ride.participantCount >= ride.participantLimit) {
|
|
showAlert('This ride is full. Its waitlist is not available yet.', 'info');
|
|
return null;
|
|
}
|
|
try {
|
|
const rideRef = doc(firestore, 'group_rides', ride.id);
|
|
const participantRef = doc(rideRef, 'participants', uid);
|
|
const member = {
|
|
userId: uid,
|
|
role: 'participant',
|
|
status: ride.joinPolicy === 'open' ? 'accepted' : 'requested',
|
|
createdAt: serverTimestamp(),
|
|
updatedAt: serverTimestamp(),
|
|
};
|
|
if (ride.joinPolicy === 'open') {
|
|
const batch = writeBatch(firestore);
|
|
batch.set(participantRef, member);
|
|
batch.update(rideRef, {
|
|
accessUids: arrayUnion(uid),
|
|
participantCount: increment(1),
|
|
updatedAt: serverTimestamp(),
|
|
});
|
|
await batch.commit();
|
|
} else {
|
|
await setDoc(participantRef, member);
|
|
}
|
|
const userStatus = ride.joinPolicy === 'open' ? 'joined' : 'requested';
|
|
setCommunityRides((prev) => prev.map((item) => item.id === rideId ? {
|
|
...item,
|
|
userStatus,
|
|
participantCount: item.participantCount + (userStatus === 'joined' ? 1 : 0),
|
|
} : item));
|
|
showAlert(userStatus === 'joined' ? `Joined "${ride.title}".` : `Join request sent for "${ride.title}".`, 'success');
|
|
return userStatus;
|
|
} catch (error) {
|
|
console.error('Failed to join group ride:', error);
|
|
showAlert('Could not join this ride. It may have filled; refresh and try again.', 'error');
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const handleJoinSuccess = async (code: string) => {
|
|
const ride = communityRides.find((item) => item.id.toUpperCase() === code.toUpperCase());
|
|
if (!ride) throw new Error('No public ride was found for that code.');
|
|
const status = await handleRequestSlot(ride.id);
|
|
if (!status) throw new Error('This ride could not be joined.');
|
|
return { title: ride.title, lead: ride.leadHandle, status };
|
|
};
|
|
|
|
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 cursor-pointer"
|
|
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/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 Squads Nearby</span>
|
|
<div className="text-2xl font-extrabold font-mono text-on-surface mt-1">
|
|
{rides.length} Squads
|
|
</div>
|
|
<span className="text-[11px] font-mono text-secondary-fixed mt-1">
|
|
Within 25 Mile Radius
|
|
</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">Total Miles in Formation</span>
|
|
<div className="text-2xl font-extrabold font-mono text-primary-container mt-1">
|
|
1,420 mi
|
|
</div>
|
|
<span className="text-[11px] font-mono text-on-surface-variant mt-1">
|
|
Squad Aggregate Logged
|
|
</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">Pack Safety Score</span>
|
|
<div className="text-2xl font-extrabold font-mono text-secondary-fixed mt-1">
|
|
98.4%
|
|
</div>
|
|
<span className="text-[11px] font-mono text-on-surface-variant mt-1">
|
|
Optimal Paceline Spacing
|
|
</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">Mesh Node Health</span>
|
|
<div className="text-2xl font-extrabold font-mono text-primary-fixed mt-1">
|
|
6/6 Nodes
|
|
</div>
|
|
<span className="text-[11px] font-mono text-secondary-fixed mt-1">
|
|
12ms Latency • Online
|
|
</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 ({communityRides.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 cursor-pointer ${
|
|
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 cursor-pointer ${
|
|
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 cursor-pointer ${
|
|
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 cursor-pointer"
|
|
>
|
|
+ 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 cursor-pointer"
|
|
>
|
|
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';
|
|
const statusText = isCanceled ? 'Canceled' : (ride.status || 'Scheduled');
|
|
const isActiveRide = ride.status === 'active' || ride.status === 'underway';
|
|
|
|
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 flex-wrap">
|
|
{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>
|
|
)}
|
|
<span
|
|
className={`px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase border ${
|
|
isCanceled
|
|
? 'bg-error/20 text-error border-error/30'
|
|
: isActiveRide
|
|
? 'bg-secondary-fixed/20 text-secondary-fixed border-secondary-fixed/30 animate-pulse'
|
|
: 'bg-surface-container-high text-on-surface-variant border-outline-variant/30'
|
|
}`}
|
|
>
|
|
{statusText}
|
|
</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 flex-wrap items-center gap-2 text-xs font-mono text-on-surface-variant mb-2">
|
|
<span className="px-1.5 py-0.5 rounded bg-surface-container text-primary-container text-[10px] font-semibold border border-outline-variant/30">
|
|
{ride.discipline || 'Sport / Canyon'}
|
|
</span>
|
|
<span className="px-1.5 py-0.5 rounded bg-surface-container text-secondary-fixed text-[10px] font-semibold border border-outline-variant/30">
|
|
{ride.paceClassification}
|
|
</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, Route Snippet */}
|
|
<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>
|
|
{ride.routeSnippet && (
|
|
<div className="flex items-center gap-1.5 text-on-surface-variant truncate">
|
|
<span className="material-symbols-outlined text-[14px] text-primary-fixed-dim">
|
|
route
|
|
</span>
|
|
<span className="truncate text-[11px] text-primary-fixed-dim">{ride.routeSnippet}</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 */}
|
|
<div className="p-space-lg pt-0 border-t border-outline-variant/20 mt-1 flex flex-wrap items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedDetailRide(ride)}
|
|
className="px-2.5 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 cursor-pointer flex items-center gap-1"
|
|
title="View full ride details"
|
|
>
|
|
<span className="material-symbols-outlined text-[15px]">info</span>
|
|
<span>Details</span>
|
|
</button>
|
|
|
|
{isActiveRide && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedRadarRideId(ride.id);
|
|
setActiveTab('radar');
|
|
}}
|
|
className="px-2.5 py-1.5 rounded-xl bg-secondary-fixed/20 hover:bg-secondary-fixed/30 border border-secondary-fixed/40 text-secondary-fixed text-xs font-semibold transition cursor-pointer flex items-center gap-1"
|
|
title="Open Live Radar feed for this active run"
|
|
>
|
|
<span className="material-symbols-outlined text-[15px]">radar</span>
|
|
<span>Open Live Radar</span>
|
|
</button>
|
|
)}
|
|
|
|
{isOrg && (
|
|
<>
|
|
<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>
|
|
|
|
<div>
|
|
{isOrg ? (
|
|
<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>
|
|
) : (
|
|
<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>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* TAB 2: EXPLORE COMMUNITY RUNS (VIEW, SEARCH, REQUEST SLOT, LOADING/ERROR/EMPTY STATES) */}
|
|
{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 cursor-pointer ${
|
|
paceFilter === 'all'
|
|
? 'bg-surface-container-high text-on-surface font-bold'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
All Runs ({communityRides.length})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPaceFilter('Casual')}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
|
paceFilter === 'Casual'
|
|
? 'bg-surface-container-high text-on-surface font-bold'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
Casual
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPaceFilter('Brisk')}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
|
paceFilter === 'Brisk'
|
|
? 'bg-surface-container-high text-on-surface font-bold'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
Brisk
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPaceFilter('Spirited')}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
|
paceFilter === 'Spirited'
|
|
? 'bg-surface-container-high text-on-surface font-bold'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
Spirited
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPaceFilter('Track')}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
|
paceFilter === 'Track'
|
|
? 'bg-surface-container-high text-on-surface font-bold'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
Track Pace
|
|
</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>
|
|
|
|
{/* 1. Loading State */}
|
|
{isCommunityLoading && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-space-lg">
|
|
{[1, 2, 3].map((skeletonIdx) => (
|
|
<div
|
|
key={`skeleton-${skeletonIdx}`}
|
|
className="bg-surface-container-low rounded-2xl border border-outline-variant/20 p-space-lg space-y-3 animate-pulse"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="h-4 w-20 bg-surface-container-high rounded" />
|
|
<div className="h-4 w-28 bg-surface-container-high rounded" />
|
|
</div>
|
|
<div className="h-6 w-3/4 bg-surface-container-high rounded mt-2" />
|
|
<div className="h-3 w-1/2 bg-surface-container-high rounded" />
|
|
<div className="h-16 w-full bg-surface-container rounded mt-3" />
|
|
<div className="h-9 w-full bg-surface-container-high rounded-xl mt-4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 2. Error State */}
|
|
{!isCommunityLoading && communityError && communityRides.length === 0 && (
|
|
<div className="text-center py-16 px-4 bg-surface-container-low rounded-2xl border border-error/30 space-y-4">
|
|
<div className="w-14 h-14 rounded-2xl bg-error/15 text-error mx-auto flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-[28px]">cloud_off</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-headline-md text-base font-bold text-on-surface">
|
|
Community rides could not be loaded
|
|
</h3>
|
|
<p className="font-body-sm text-xs text-on-surface-variant mt-1 max-w-md mx-auto">
|
|
{communityError}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-center gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadCommunityRides()}
|
|
className="px-4 py-2 rounded-xl bg-primary-container hover:bg-primary-fixed text-on-primary-container text-xs font-bold transition flex items-center gap-1.5 cursor-pointer shadow-sm"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
|
<span>Retry Loading</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
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 cursor-pointer"
|
|
>
|
|
+ Create Group Ride
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 3. Empty State */}
|
|
{!isCommunityLoading && (!communityError || communityRides.length > 0) && discoverRides.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]">explore_off</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-headline-md text-base font-bold text-on-surface">
|
|
{searchQuery || paceFilter !== 'all' ? 'No Matching Pack Runs' : 'No Community Rides Scheduled'}
|
|
</h3>
|
|
<p className="font-body-sm text-xs text-on-surface-variant mt-1 max-w-md mx-auto">
|
|
{searchQuery || paceFilter !== 'all'
|
|
? 'No upcoming pack runs match your current search or pace filter. Try resetting filters to explore all community runs.'
|
|
: 'No public pack formations are scheduled at this time. Create the first squad run and invite riders!'}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-center gap-3 pt-2">
|
|
{searchQuery || paceFilter !== 'all' ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
setPaceFilter('all');
|
|
}}
|
|
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 cursor-pointer"
|
|
>
|
|
Reset Filters
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
className="px-4 py-2 rounded-xl bg-primary-container text-on-primary-container text-xs font-bold cursor-pointer"
|
|
>
|
|
+ Create Group Ride
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 4. Populated State (Directory Grid) */}
|
|
{!isCommunityLoading && discoverRides.length > 0 && (
|
|
<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;
|
|
const isRequested = ride.userStatus === 'requested';
|
|
|
|
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>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs font-mono text-on-surface-variant mb-2">
|
|
<span className="px-1.5 py-0.5 rounded bg-surface-container text-primary-container text-[10px] font-semibold border border-outline-variant/30">
|
|
{ride.discipline || 'Sport / Canyon'}
|
|
</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>
|
|
|
|
<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>
|
|
{ride.routeSnippet && (
|
|
<div className="flex items-center gap-1.5 truncate text-[11px] text-primary-fixed-dim">
|
|
<span className="material-symbols-outlined text-[14px]">route</span>
|
|
<span className="truncate">{ride.routeSnippet}</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 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedDetailRide(ride)}
|
|
className="px-3 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"
|
|
title="View full ride parameters"
|
|
>
|
|
Details
|
|
</button>
|
|
{isJoined ? (
|
|
<div className="flex-1 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>
|
|
) : isRequested ? (
|
|
<div className="flex-1 py-2 rounded-xl bg-surface-container text-on-surface-variant text-xs font-semibold text-center">
|
|
Request Pending
|
|
</div>
|
|
) : isFull ? (
|
|
<button type="button" disabled className="flex-1 py-2 rounded-xl bg-surface-container text-on-surface-variant text-xs font-semibold cursor-not-allowed">
|
|
Ride Full
|
|
</button>
|
|
) : ride.joinPolicy === 'inviteOnly' ? (
|
|
<button type="button" disabled className="flex-1 py-2 rounded-xl bg-surface-container text-on-surface-variant text-xs font-semibold cursor-not-allowed">
|
|
Invite Only
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRequestSlot(ride.id)}
|
|
className="flex-1 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 cursor-pointer"
|
|
>
|
|
<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 flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-surface-container-low border border-outline-variant/20">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="w-2 h-2 rounded-full bg-secondary-fixed animate-ping" />
|
|
<span className="font-mono text-xs text-primary-container font-semibold">
|
|
Live Squad Telemetry Stream: Connected
|
|
</span>
|
|
</div>
|
|
<h2 className="font-headline-lg text-xl font-bold text-on-surface">
|
|
Live Formation Radar Cockpit
|
|
</h2>
|
|
<p className="font-body-sm text-xs text-on-surface-variant mt-0.5">
|
|
{selectedRadarRide
|
|
? `Formation session #${selectedRadarRide.rideCode || selectedRadarRide.id} • ${selectedRadarRide.participantCount} squad nodes reporting GNSS multi-band coordinates and lean angles.`
|
|
: 'Formation session #CA-84-SW • 6 squad nodes reporting sub-second GNSS multi-band coordinates and lean angles.'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-3 py-1 rounded-xl bg-surface-container border border-outline-variant/30 text-xs font-mono text-secondary-fixed">
|
|
Mesh Link: 12ms Latency
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<LivePackRadar
|
|
initialMode="tactical"
|
|
selectedRide={selectedRadarRide}
|
|
allRides={rides}
|
|
onSelectRide={(r) => setSelectedRadarRideId(r.id)}
|
|
currentUser={currentUser}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{/* Modals */}
|
|
<CreateGroupRideModal
|
|
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}
|
|
/>
|
|
|
|
<RideDetailsModal
|
|
isOpen={Boolean(selectedDetailRide)}
|
|
ride={selectedDetailRide}
|
|
onClose={() => setSelectedDetailRide(null)}
|
|
onEdit={(r) => setEditingRide(r)}
|
|
onRequestSlot={handleRequestSlot}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|