feat(website): discrete group rides showcase and management architecture lock
This commit is contained in:
parent
69fefd7b56
commit
f20a09addf
|
|
@ -1,12 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 router = useRouter();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(firebaseAuth?.currentUser ?? null);
|
||||
const [authChecked, setAuthChecked] = useState(Boolean(firebaseAuth?.currentUser));
|
||||
|
||||
|
|
@ -14,40 +15,25 @@ export default function GroupRidesManageRoute() {
|
|||
const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
|
||||
setCurrentUser(user);
|
||||
setAuthChecked(true);
|
||||
if (!user) {
|
||||
router.replace('/login?redirect=/group-rides/manage');
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
}, [router]);
|
||||
|
||||
// If not logged in, render an access prompt directing them to sign in
|
||||
if (!currentUser && authChecked) {
|
||||
// If auth is still checking or user is not logged in (and redirecting), show clean loading skeleton
|
||||
if (!authChecked || !currentUser) {
|
||||
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
|
||||
className="flagship-page min-h-screen bg-surface text-on-surface flex flex-col justify-center items-center px-4 py-16"
|
||||
data-testid="manage-auth-loading"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-primary-container border-t-transparent animate-spin" />
|
||||
<span className="font-mono text-xs text-on-surface-variant">
|
||||
Verifying rider credentials & permissions...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -55,3 +41,4 @@ export default function GroupRidesManageRoute() {
|
|||
|
||||
return <GroupRidesManagement currentUser={currentUser} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,51 +8,39 @@ import { GroupRidesManagement } from '../../components/group-rides/group-rides-m
|
|||
|
||||
export default function GroupRidesPage() {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(firebaseAuth?.currentUser ?? null);
|
||||
const [viewOverride, setViewOverride] = useState<'showcase' | 'manage' | null>(null);
|
||||
const [authChecked, setAuthChecked] = useState(Boolean(firebaseAuth?.currentUser));
|
||||
|
||||
useEffect(() => {
|
||||
// Check URL parameters for explicit view overrides (?view=showcase or ?view=manage)
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const view = params.get('view');
|
||||
if (view === 'showcase' || view === 'manage') {
|
||||
setViewOverride(view);
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
|
||||
setCurrentUser(user);
|
||||
setAuthChecked(true);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// If explicit showcase view requested
|
||||
if (viewOverride === 'showcase') {
|
||||
// Loading skeleton to eliminate page flickering while auth state resolves
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<GroupRidesShowcase
|
||||
isLoggedIn={Boolean(currentUser)}
|
||||
userEmail={currentUser?.email}
|
||||
onGoToManagement={() => setViewOverride('manage')}
|
||||
/>
|
||||
<div
|
||||
className="flagship-page min-h-screen bg-surface text-on-surface flex flex-col justify-center items-center px-4 py-16"
|
||||
data-testid="group-rides-skeleton"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center max-w-sm w-full">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-primary-container border-t-transparent animate-spin" />
|
||||
<div className="w-48 h-4 bg-surface-container-high rounded animate-pulse" />
|
||||
<div className="w-32 h-3 bg-surface-container rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user is authenticated: render Group Rides Management Console
|
||||
// If user is authenticated: render Group Rides Hub / Management Console
|
||||
if (currentUser) {
|
||||
return (
|
||||
<GroupRidesManagement
|
||||
currentUser={currentUser}
|
||||
onViewShowcase={() => setViewOverride('showcase')}
|
||||
/>
|
||||
);
|
||||
return <GroupRidesManagement currentUser={currentUser} />;
|
||||
}
|
||||
|
||||
// If user is not authenticated: render Group Rides Feature Showcase Page
|
||||
return (
|
||||
<GroupRidesShowcase
|
||||
isLoggedIn={false}
|
||||
onGoToManagement={() => setViewOverride('manage')}
|
||||
/>
|
||||
);
|
||||
return <GroupRidesShowcase isLoggedIn={false} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
const [scheduledDate, setScheduledDate] = useState('Saturday');
|
||||
const [scheduledTime, setScheduledTime] = useState('07:00 AM PST');
|
||||
const [paceClassification, setPaceClassification] = useState<GroupRideSummary['paceClassification']>('Brisk Pace');
|
||||
const [discipline, setDiscipline] = useState<NonNullable<GroupRideSummary['discipline']>>('Sport / Canyon');
|
||||
const [joinPolicy, setJoinPolicy] = useState<GroupRideSummary['joinPolicy']>('requestToJoin');
|
||||
const [formationProfile, setFormationProfile] = useState('Staggered Dual Column [B]');
|
||||
const [distance, setDistance] = useState('50.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);
|
||||
|
|
@ -30,9 +33,12 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
setScheduledDate('Saturday');
|
||||
setScheduledTime('07:00 AM PST');
|
||||
setPaceClassification('Brisk Pace');
|
||||
setDiscipline('Sport / Canyon');
|
||||
setJoinPolicy('requestToJoin');
|
||||
setFormationProfile('Staggered Dual Column [B]');
|
||||
setDistance('50.0 Miles');
|
||||
setMeetingPoint('');
|
||||
setRouteSnippet('Skyline Blvd > Stage Rd > PCH Pacific Coast Hwy');
|
||||
setParticipantLimit(12);
|
||||
setDescription('');
|
||||
setErrorMsg(null);
|
||||
|
|
@ -72,15 +78,18 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
scheduledDate,
|
||||
scheduledTime,
|
||||
paceClassification,
|
||||
discipline,
|
||||
formationProfile,
|
||||
joinPolicy: 'requestToJoin',
|
||||
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',
|
||||
};
|
||||
|
||||
onCreateRide(newRide);
|
||||
|
|
@ -238,6 +247,41 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discipline & Privacy Controls */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-space-md">
|
||||
<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>
|
||||
<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="open">Public (Open to All Riders)</option>
|
||||
<option value="requestToJoin">Moderated (Request Slot Required)</option>
|
||||
<option value="inviteOnly">Private (Invite / Code Only)</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">
|
||||
|
|
@ -253,6 +297,32 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Route Upload or Waypoint Selection */}
|
||||
<div>
|
||||
<label htmlFor="ride-route-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
Route Upload or Waypoint Selection
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="ride-route-input"
|
||||
type="text"
|
||||
value={routeSnippet}
|
||||
onChange={(e) => setRouteSnippet(e.target.value)}
|
||||
placeholder="e.g. Skyline Blvd > Stage Rd > Pescadero Creek"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRouteSnippet('Custom GPX Track Loaded (34.2 mi • 82 curves)')}
|
||||
className="px-3 py-2 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs font-mono text-primary-container transition flex items-center gap-1"
|
||||
title="Simulate GPX Upload"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">upload_file</span>
|
||||
<span>GPX</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="ride-desc-input" className="block font-label-caps text-label-caps text-on-surface-variant mb-1">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ export const INITIAL_RADAR_RIDERS: RadarRiderTelemetry[] = [
|
|||
brakePct: 0,
|
||||
gapMeters: 0,
|
||||
gapDeltaSec: 0,
|
||||
batteryPct: 96,
|
||||
meshPingMs: 9,
|
||||
color: '#00F2FE', // cyan
|
||||
status: 'active',
|
||||
coordinates: { x: 84, y: 18 },
|
||||
|
|
@ -27,6 +29,8 @@ export const INITIAL_RADAR_RIDERS: RadarRiderTelemetry[] = [
|
|||
brakePct: 0,
|
||||
gapMeters: 45,
|
||||
gapDeltaSec: 0.4,
|
||||
batteryPct: 91,
|
||||
meshPingMs: 11,
|
||||
color: '#00F2FE',
|
||||
status: 'active',
|
||||
coordinates: { x: 71, y: 32 },
|
||||
|
|
@ -42,6 +46,8 @@ export const INITIAL_RADAR_RIDERS: RadarRiderTelemetry[] = [
|
|||
brakePct: 0,
|
||||
gapMeters: 110,
|
||||
gapDeltaSec: 0.9,
|
||||
batteryPct: 84,
|
||||
meshPingMs: 14,
|
||||
color: '#FF6B6B',
|
||||
status: 'warning',
|
||||
alertText: 'Rear tire 28 PSI (low)',
|
||||
|
|
@ -58,6 +64,8 @@ export const INITIAL_RADAR_RIDERS: RadarRiderTelemetry[] = [
|
|||
brakePct: 0,
|
||||
gapMeters: 180,
|
||||
gapDeltaSec: 1.4,
|
||||
batteryPct: 88,
|
||||
meshPingMs: 12,
|
||||
color: '#00F2FE',
|
||||
status: 'active',
|
||||
coordinates: { x: 36, y: 60 },
|
||||
|
|
@ -73,6 +81,8 @@ export const INITIAL_RADAR_RIDERS: RadarRiderTelemetry[] = [
|
|||
brakePct: 85,
|
||||
gapMeters: 320,
|
||||
gapDeltaSec: 2.5,
|
||||
batteryPct: 79,
|
||||
meshPingMs: 16,
|
||||
color: '#FF4444',
|
||||
status: 'alert',
|
||||
alertText: 'Hard Braking (-1.2G) ABS Engaged',
|
||||
|
|
@ -89,6 +99,8 @@ export const INITIAL_RADAR_RIDERS: RadarRiderTelemetry[] = [
|
|||
brakePct: 0,
|
||||
gapMeters: 450,
|
||||
gapDeltaSec: 3.8,
|
||||
batteryPct: 94,
|
||||
meshPingMs: 10,
|
||||
color: '#39FF14', // neon green
|
||||
status: 'sweeper',
|
||||
coordinates: { x: 12, y: 88 },
|
||||
|
|
@ -104,6 +116,7 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
scheduledDate: 'Saturday',
|
||||
scheduledTime: '06:30 AM PST',
|
||||
paceClassification: 'Track Pace',
|
||||
discipline: 'Sport / Canyon',
|
||||
formationProfile: 'Staggered Dual Column [B]',
|
||||
joinPolicy: 'requestToJoin',
|
||||
leadHandle: 'ApexLead',
|
||||
|
|
@ -112,9 +125,10 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
participantLimit: 16,
|
||||
distance: '82.4 Miles',
|
||||
meetingPoint: "Alice's Restaurant, Skyline Blvd",
|
||||
routeSnippet: 'Woodside > Hwy 84 > Stage Rd > Pescadero Creek > Skyline Summit',
|
||||
userStatus: 'joined',
|
||||
isOrganizer: true,
|
||||
status: 'scheduled',
|
||||
status: 'active',
|
||||
rideCode: 'APEX84',
|
||||
clubName: 'NorCal Apex Guild',
|
||||
},
|
||||
|
|
@ -126,6 +140,7 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
scheduledDate: 'Sunday',
|
||||
scheduledTime: '07:15 AM PST',
|
||||
paceClassification: 'Brisk Pace',
|
||||
discipline: 'Sport / Canyon',
|
||||
formationProfile: 'Staggered Paceline',
|
||||
joinPolicy: 'requestToJoin',
|
||||
leadHandle: 'Dan_KTM',
|
||||
|
|
@ -134,9 +149,10 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
participantLimit: 12,
|
||||
distance: '64.0 Miles',
|
||||
meetingPoint: 'Rock Store Overlook',
|
||||
routeSnippet: 'PCH > Topanga > Tuna Canyon > Latigo Canyon Overlook',
|
||||
userStatus: 'joined',
|
||||
isOrganizer: false,
|
||||
status: 'scheduled',
|
||||
status: 'upcoming',
|
||||
rideCode: 'MALIBU22',
|
||||
clubName: 'Pacific Coast Riders',
|
||||
},
|
||||
|
|
@ -148,6 +164,7 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
scheduledDate: 'Fri-Sun',
|
||||
scheduledTime: 'Oct 12-14',
|
||||
paceClassification: 'Endurance / Chill',
|
||||
discipline: 'Track / Circuit',
|
||||
formationProfile: 'Dynamic Paceline',
|
||||
joinPolicy: 'open',
|
||||
leadHandle: 'Sweep_Marshal',
|
||||
|
|
@ -156,6 +173,7 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
participantLimit: 24,
|
||||
distance: '318 Curves',
|
||||
meetingPoint: 'Deal’s Gap Motorcycle Resort',
|
||||
routeSnippet: 'US 129 Deals Gap > Calderwood Dam Turnout > Cheoah Lake Overlook',
|
||||
userStatus: 'none',
|
||||
isOrganizer: false,
|
||||
status: 'scheduled',
|
||||
|
|
@ -169,6 +187,7 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
scheduledDate: 'Saturday',
|
||||
scheduledTime: '06:00 AM PST',
|
||||
paceClassification: 'Brisk Pace',
|
||||
discipline: 'Sport / Canyon',
|
||||
formationProfile: 'Staggered Dual Column',
|
||||
joinPolicy: 'open',
|
||||
leadHandle: 'Ghost99',
|
||||
|
|
@ -177,9 +196,10 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
participantLimit: 10,
|
||||
distance: '52.8 Miles',
|
||||
meetingPoint: 'Shell Station at Foothill Blvd',
|
||||
routeSnippet: 'Foothill Blvd > Red Box > Mt Wilson Observatory Turnoff > Newcomb’s Ranch',
|
||||
userStatus: 'none',
|
||||
isOrganizer: false,
|
||||
status: 'scheduled',
|
||||
status: 'upcoming',
|
||||
rideCode: 'CREST06',
|
||||
},
|
||||
{
|
||||
|
|
@ -190,6 +210,7 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
scheduledDate: 'Weekend',
|
||||
scheduledTime: 'Aug 18-20',
|
||||
paceClassification: 'Weekend Tours',
|
||||
discipline: 'Touring / ADV',
|
||||
formationProfile: 'Open Touring Formation',
|
||||
joinPolicy: 'open',
|
||||
leadHandle: 'Sarah_RR',
|
||||
|
|
@ -198,6 +219,7 @@ export const INITIAL_GROUP_RIDES: GroupRideSummary[] = [
|
|||
participantLimit: 15,
|
||||
distance: '134.5 Miles',
|
||||
meetingPoint: 'Lolo Hot Springs Rest Area',
|
||||
routeSnippet: 'US 12 West > Lochsa River Canyon > Powell Junction > Lolo Pass Summit',
|
||||
userStatus: 'none',
|
||||
isOrganizer: false,
|
||||
status: 'scheduled',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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?: { email?: string | null; displayName?: string | null } | null;
|
||||
|
|
@ -34,6 +35,7 @@ export function GroupRidesManagement({
|
|||
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<{
|
||||
|
|
@ -84,7 +86,14 @@ export function GroupRidesManagement({
|
|||
const discoverRides = useMemo(() => {
|
||||
let list = rides;
|
||||
if (paceFilter !== 'all') {
|
||||
list = list.filter((r) => r.paceClassification.toLowerCase().includes(paceFilter.toLowerCase()));
|
||||
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();
|
||||
|
|
@ -92,7 +101,8 @@ export function GroupRidesManagement({
|
|||
(r) =>
|
||||
r.title.toLowerCase().includes(q) ||
|
||||
r.region.toLowerCase().includes(q) ||
|
||||
r.leadHandle.toLowerCase().includes(q)
|
||||
r.leadHandle.toLowerCase().includes(q) ||
|
||||
(r.discipline && r.discipline.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return list;
|
||||
|
|
@ -246,7 +256,7 @@ export function GroupRidesManagement({
|
|||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/group-rides?view=showcase"
|
||||
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"
|
||||
>
|
||||
|
|
@ -311,42 +321,42 @@ export function GroupRidesManagement({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics KPI Cards */}
|
||||
{/* Metrics KPI Cards: Active Squads Nearby, Total Miles in Formation, Pack Safety Score, Mesh Node Health */}
|
||||
<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>
|
||||
<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">
|
||||
{myTotalRides.length}
|
||||
{rides.length} Squads
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-secondary-fixed mt-1">
|
||||
{myOrganizedRides.length} Lead • {myJoinedRides.length} Wingman
|
||||
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">Rides Organized</span>
|
||||
<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">
|
||||
{myOrganizedRides.length}
|
||||
1,420 mi
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-on-surface-variant mt-1">
|
||||
Road Captain Authority
|
||||
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">Squad Formations</span>
|
||||
<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">
|
||||
{rides.length} Total
|
||||
98.4%
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-on-surface-variant mt-1">
|
||||
In Regional Network
|
||||
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">Live Pack Radar</span>
|
||||
<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">
|
||||
Online
|
||||
6/6 Nodes
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-secondary-fixed mt-1">
|
||||
6-Node Telemetry Mesh
|
||||
12ms Latency • Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -487,6 +497,9 @@ export function GroupRidesManagement({
|
|||
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}
|
||||
|
|
@ -499,7 +512,7 @@ export function GroupRidesManagement({
|
|||
<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">
|
||||
<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)
|
||||
|
|
@ -509,11 +522,17 @@ export function GroupRidesManagement({
|
|||
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>
|
||||
)}
|
||||
<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 */}
|
||||
|
|
@ -534,11 +553,13 @@ export function GroupRidesManagement({
|
|||
<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
|
||||
<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>{ride.region}</span>
|
||||
<span>•</span>
|
||||
<span>{ride.distance}</span>
|
||||
</div>
|
||||
|
|
@ -547,7 +568,7 @@ export function GroupRidesManagement({
|
|||
{ride.description}
|
||||
</p>
|
||||
|
||||
{/* Meeting Point & Schedule */}
|
||||
{/* 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">
|
||||
|
|
@ -561,6 +582,14 @@ export function GroupRidesManagement({
|
|||
</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
|
||||
|
|
@ -572,11 +601,33 @@ export function GroupRidesManagement({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Controls Row (ALTER, REMOVE, CANCEL, LEAVE) */}
|
||||
{/* Management Controls Row (VIEW DETAILS, ALTER, REMOVE, CANCEL, LEAVE, RADAR) */}
|
||||
<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">
|
||||
<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={() => 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)}
|
||||
|
|
@ -596,8 +647,12 @@ export function GroupRidesManagement({
|
|||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isOrg ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletingRide(ride)}
|
||||
|
|
@ -607,13 +662,7 @@ export function GroupRidesManagement({
|
|||
<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)}
|
||||
|
|
@ -623,8 +672,8 @@ export function GroupRidesManagement({
|
|||
<span className="material-symbols-outlined text-[15px]">logout</span>
|
||||
<span>Leave Pack</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -651,6 +700,17 @@ export function GroupRidesManagement({
|
|||
>
|
||||
All Runs ({rides.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaceFilter('Casual')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||
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')}
|
||||
|
|
@ -660,7 +720,18 @@ export function GroupRidesManagement({
|
|||
: 'text-on-surface-variant hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
Brisk Pace
|
||||
Brisk
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaceFilter('Spirited')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
|
||||
paceFilter === 'Spirited'
|
||||
? 'bg-surface-container-high text-on-surface font-bold'
|
||||
: 'text-on-surface-variant hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
Spirited
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -673,17 +744,6 @@ export function GroupRidesManagement({
|
|||
>
|
||||
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">
|
||||
|
|
@ -724,6 +784,14 @@ export function GroupRidesManagement({
|
|||
<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>
|
||||
|
|
@ -741,6 +809,12 @@ export function GroupRidesManagement({
|
|||
</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">
|
||||
|
|
@ -751,9 +825,17 @@ export function GroupRidesManagement({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-space-lg pt-0">
|
||||
<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"
|
||||
title="View full ride parameters"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
{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">
|
||||
<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>
|
||||
|
|
@ -761,7 +843,7 @@ export function GroupRidesManagement({
|
|||
<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"
|
||||
className="flex-1 py-2 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface text-xs font-semibold transition"
|
||||
>
|
||||
Join Waitlist
|
||||
</button>
|
||||
|
|
@ -769,7 +851,7 @@ export function GroupRidesManagement({
|
|||
<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"
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">how_to_reg</span>
|
||||
<span>Request Pack Slot</span>
|
||||
|
|
@ -786,16 +868,26 @@ export function GroupRidesManagement({
|
|||
{/* 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 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">
|
||||
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" />
|
||||
</section>
|
||||
|
|
@ -842,6 +934,14 @@ export function GroupRidesManagement({
|
|||
onClose={() => setIsJoinModalOpen(false)}
|
||||
onJoinSuccess={handleJoinSuccess}
|
||||
/>
|
||||
|
||||
<RideDetailsModal
|
||||
isOpen={Boolean(selectedDetailRide)}
|
||||
ride={selectedDetailRide}
|
||||
onClose={() => setSelectedDetailRide(null)}
|
||||
onEdit={(r) => setEditingRide(r)}
|
||||
onRequestSlot={handleRequestSlot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,12 +41,12 @@ export function GroupRidesShowcase({
|
|||
|
||||
{/* 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="bg-primary-container/15 border-b border-primary-container/30 py-2.5 px-4" data-testid="showcase-logged-in-banner">
|
||||
<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)'}
|
||||
You're viewing the public showcase.{userEmail ? ` (${userEmail})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{onGoToManagement ? (
|
||||
|
|
@ -55,15 +55,15 @@ export function GroupRidesShowcase({
|
|||
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>Return to Group Rides Console</span>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/group-rides?view=manage"
|
||||
href="/group-rides/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>Return to Group Rides Console</span>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||
</Link>
|
||||
)}
|
||||
|
|
@ -114,7 +114,7 @@ export function GroupRidesShowcase({
|
|||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/group-rides?view=manage"
|
||||
href="/group-rides/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>
|
||||
|
|
@ -140,6 +140,15 @@ export function GroupRidesShowcase({
|
|||
<span className="material-symbols-outlined text-[20px]">how_to_reg</span>
|
||||
Get Started Free
|
||||
</Link>
|
||||
<Link
|
||||
href="/download"
|
||||
className="inline-flex items-center justify-center gap-space-xs px-space-lg py-3.5 rounded-xl bg-surface-container hover:bg-surface-container-high text-on-surface font-headline-sm text-headline-sm transition-all border border-outline-variant/30"
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary-container text-[20px]">
|
||||
download
|
||||
</span>
|
||||
Download App
|
||||
</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"
|
||||
|
|
@ -206,7 +215,7 @@ export function GroupRidesShowcase({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 2: Crash & Separation Alerts */}
|
||||
{/* Feature 2: Swarm Broadcast & Crash Separation */}
|
||||
<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">
|
||||
|
|
@ -216,7 +225,7 @@ export function GroupRidesShowcase({
|
|||
02 / Swarm Broadcast
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
Crash & Separation Alerts
|
||||
Swarm Broadcast & Crash Separation
|
||||
</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
|
||||
|
|
@ -262,7 +271,7 @@ export function GroupRidesShowcase({
|
|||
04 / Mission Debrief
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
3D Multi-Bike Replay
|
||||
3D Multi-Bike Replay & Debrief
|
||||
</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,
|
||||
|
|
@ -275,7 +284,7 @@ export function GroupRidesShowcase({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 5: Multi-Band GNSS Formation Lock */}
|
||||
{/* Feature 5: Multi-Band GNSS Precision */}
|
||||
<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">
|
||||
|
|
@ -285,7 +294,7 @@ export function GroupRidesShowcase({
|
|||
05 / Precision Mesh
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
Multi-Band GNSS Lock
|
||||
Multi-Band GNSS Precision
|
||||
</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
|
||||
|
|
@ -298,25 +307,25 @@ export function GroupRidesShowcase({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 6: Dynamic Checkpoint Auto-Regroup */}
|
||||
{/* Feature 6: Offline Cell-Tower Fallback */}
|
||||
<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>
|
||||
<span className="material-symbols-outlined text-[26px]">cell_tower</span>
|
||||
</div>
|
||||
<span className="font-code-mono text-code-mono text-primary-container block mb-1">
|
||||
06 / Intelligent Checkpoints
|
||||
06 / Zero-Cell Fallback
|
||||
</span>
|
||||
<h3 className="font-headline-md text-headline-md text-on-surface mb-space-sm">
|
||||
Auto-Regroup Checkpoints
|
||||
Offline Cell-Tower Fallback
|
||||
</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.
|
||||
Zero cellular dependency. Dynamic mesh packet hopping ensures formation telemetry,
|
||||
deceleration warnings, and regroup coordinates stay live without cell tower connectivity.
|
||||
</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>Zero-Cell Resilient</span>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -336,22 +345,22 @@ export function GroupRidesShowcase({
|
|||
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.
|
||||
Integrates seamlessly with Cardo DMC, Sena Mesh, Bluetooth LE, and Android Auto / Apple CarPlay
|
||||
dashboards 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>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Cardo DMC & Sena Mesh</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>
|
||||
<span className="material-symbols-outlined text-primary-container">bluetooth</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Bluetooth LE</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>
|
||||
<span className="material-symbols-outlined text-secondary-fixed-dim">directions_car</span>
|
||||
<span className="font-code-mono text-code-mono text-on-surface">Android Auto / CarPlay</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -474,7 +483,7 @@ export function GroupRidesShowcase({
|
|||
<div className="p-space-lg pt-0">
|
||||
{isLoggedIn ? (
|
||||
<Link
|
||||
href="/group-rides?view=manage"
|
||||
href="/group-rides/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>
|
||||
|
|
|
|||
|
|
@ -369,22 +369,23 @@ export function LivePackRadar({ initialMode = 'tactical' }: LivePackRadarProps)
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Normal Telemetry Grid */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 font-code-mono text-code-mono text-on-surface-variant">
|
||||
{/* Squad Roster Telemetry Grid: Speed, Lean, Battery, Mesh Ping */}
|
||||
<div className="grid grid-cols-4 gap-1.5 mt-2 font-code-mono text-[11px] text-on-surface-variant">
|
||||
<div>
|
||||
Speed: <span className="text-on-surface font-bold">{rider.speedMph} MPH</span>
|
||||
<span className="block text-[9px] text-outline">Speed</span>
|
||||
<span className="text-on-surface font-bold">{rider.speedMph} MPH</span>
|
||||
</div>
|
||||
<div>
|
||||
Lean: <span className="text-primary-container font-bold">{rider.leanAngleDeg}°</span>
|
||||
<span className="block text-[9px] text-outline">Lean</span>
|
||||
<span className="text-primary-container font-bold">{rider.leanAngleDeg}°</span>
|
||||
</div>
|
||||
<div>
|
||||
{rider.brakePct && rider.brakePct > 0 ? (
|
||||
<span className="text-error font-bold">Brake: {rider.brakePct}%</span>
|
||||
) : rider.throttlePct !== undefined ? (
|
||||
<span>Throttle: {rider.throttlePct}%</span>
|
||||
) : (
|
||||
<span>Delta: ±{rider.gapDeltaSec}s</span>
|
||||
)}
|
||||
<span className="block text-[9px] text-outline">Battery</span>
|
||||
<span className="text-secondary-fixed font-bold">{rider.batteryPct ?? 92}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[9px] text-outline">Mesh Ping</span>
|
||||
<span className="text-primary-fixed-dim font-bold">{rider.meshPingMs ?? 12}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { GroupRideSummary } from './types';
|
||||
|
||||
interface RideDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
ride: GroupRideSummary | null;
|
||||
onClose: () => void;
|
||||
onEdit?: (ride: GroupRideSummary) => void;
|
||||
onRequestSlot?: (rideId: string) => void;
|
||||
}
|
||||
|
||||
export function RideDetailsModal({
|
||||
isOpen,
|
||||
ride,
|
||||
onClose,
|
||||
onEdit,
|
||||
onRequestSlot,
|
||||
}: RideDetailsModalProps) {
|
||||
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 isFull = ride.participantCount >= ride.participantLimit;
|
||||
const isJoined = ride.userStatus === 'joined' || ride.isOrganizer;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ride-details-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"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative w-full max-w-lg 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-10 h-10 rounded-xl bg-primary-container/20 text-primary-container flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-[24px]">sports_motorsports</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="ride-details-title" className="font-headline text-lg font-bold text-on-surface">
|
||||
{ride.title}
|
||||
</h2>
|
||||
<span className="font-mono text-xs text-primary-container font-semibold">
|
||||
{ride.discipline || 'Sport / Canyon'} • {ride.paceClassification}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg bg-surface-container hover:bg-surface-container-high text-on-surface-variant flex items-center justify-center transition cursor-pointer"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status & Code Row */}
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-surface-container border border-outline-variant/20 text-xs font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-on-surface-variant">Status:</span>
|
||||
<span className="px-2 py-0.5 rounded bg-primary-container/20 text-primary-container font-bold uppercase text-[10px]">
|
||||
{ride.status || 'scheduled'}
|
||||
</span>
|
||||
</div>
|
||||
{ride.rideCode && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-on-surface-variant">Code:</span>
|
||||
<span className="px-2 py-0.5 rounded bg-surface-container-highest text-secondary-fixed font-bold">
|
||||
{ride.rideCode}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detailed Metadata Grid */}
|
||||
<div className="space-y-3 text-xs font-mono">
|
||||
<div className="p-3 rounded-xl bg-surface-container/60 border border-outline-variant/20 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-on-surface-variant">Date & Time:</span>
|
||||
<span className="text-on-surface font-semibold">
|
||||
{ride.scheduledDate} • {ride.scheduledTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-on-surface-variant">Region:</span>
|
||||
<span className="text-on-surface">{ride.region}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-on-surface-variant">Total Distance:</span>
|
||||
<span className="text-on-surface">{ride.distance}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-on-surface-variant">Formation Profile:</span>
|
||||
<span className="text-secondary-fixed">{ride.formationProfile}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-on-surface-variant">Rider Roster:</span>
|
||||
<span className="text-on-surface font-bold">
|
||||
{ride.participantCount} / {ride.participantLimit} Riders
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-on-surface-variant">Road Captain / Lead:</span>
|
||||
<span className="text-primary-container font-bold">{ride.leadHandle}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meeting Point */}
|
||||
<div>
|
||||
<span className="block text-[11px] text-outline font-semibold uppercase mb-1">
|
||||
Meeting Point & Rally Location
|
||||
</span>
|
||||
<div className="p-3 rounded-xl bg-surface-container/80 border border-outline-variant/20 text-on-surface">
|
||||
{ride.meetingPoint}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Preview Snippet */}
|
||||
<div>
|
||||
<span className="block text-[11px] text-outline font-semibold uppercase mb-1">
|
||||
Route Preview Waypoints
|
||||
</span>
|
||||
<div className="p-3 rounded-xl bg-surface-container/80 border border-outline-variant/20 text-primary-fixed-dim">
|
||||
{ride.routeSnippet || 'Waypoints synchronized via MotorSignal GNSS Multi-Band Mesh.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description & Safety Briefing */}
|
||||
<div>
|
||||
<span className="block text-[11px] text-outline font-semibold uppercase mb-1">
|
||||
Briefing & Safety Protocol
|
||||
</span>
|
||||
<p className="p-3 rounded-xl bg-surface-container/80 border border-outline-variant/20 font-body text-xs text-on-surface-variant leading-relaxed">
|
||||
{ride.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<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 text-xs font-semibold text-on-surface transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{ride.isOrganizer && onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onEdit(ride);
|
||||
}}
|
||||
className="px-4 py-2 rounded-xl bg-primary-container text-on-primary-container text-xs font-bold hover:shadow-lg transition cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">edit</span>
|
||||
<span>Edit Ride</span>
|
||||
</button>
|
||||
)}
|
||||
{!ride.isOrganizer && !isJoined && onRequestSlot && !isFull && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onRequestSlot(ride.id);
|
||||
}}
|
||||
className="px-4 py-2 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container text-xs font-bold hover:shadow-lg transition cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">how_to_reg</span>
|
||||
<span>Join Pack Run</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ export interface RadarRiderTelemetry {
|
|||
brakePct?: number;
|
||||
gapMeters: number;
|
||||
gapDeltaSec: number;
|
||||
batteryPct?: number;
|
||||
meshPingMs?: number;
|
||||
color: string;
|
||||
status: 'active' | 'warning' | 'alert' | 'sweeper';
|
||||
alertText?: string;
|
||||
|
|
@ -28,7 +30,8 @@ export interface GroupRideSummary {
|
|||
region: string;
|
||||
scheduledTime: string;
|
||||
scheduledDate: string;
|
||||
paceClassification: 'Track Pace' | 'Brisk Pace' | 'Endurance / Chill' | 'Weekend Tours';
|
||||
paceClassification: 'Track Pace' | 'Brisk Pace' | 'Spirited' | 'Casual' | 'Endurance / Chill' | 'Weekend Tours';
|
||||
discipline?: 'Sport / Canyon' | 'Touring / ADV' | 'Track / Circuit' | 'Cruiser / Pack';
|
||||
formationProfile: string;
|
||||
joinPolicy: 'open' | 'requestToJoin' | 'inviteOnly';
|
||||
leadHandle: string;
|
||||
|
|
@ -37,9 +40,10 @@ export interface GroupRideSummary {
|
|||
participantLimit: number;
|
||||
distance: string;
|
||||
meetingPoint: string;
|
||||
routeSnippet?: string;
|
||||
userStatus?: 'none' | 'requested' | 'joined' | 'waitlisted';
|
||||
isOrganizer?: boolean;
|
||||
status?: 'scheduled' | 'assembling' | 'underway' | 'completed' | 'canceled';
|
||||
status?: 'draft' | 'scheduled' | 'upcoming' | 'active' | 'underway' | 'completed' | 'canceled';
|
||||
rideCode?: string;
|
||||
clubName?: string;
|
||||
}
|
||||
|
|
@ -49,12 +53,14 @@ export interface CreateRideFormData {
|
|||
region: string;
|
||||
description: string;
|
||||
paceClassification: GroupRideSummary['paceClassification'];
|
||||
discipline?: GroupRideSummary['discipline'];
|
||||
formationProfile: string;
|
||||
joinPolicy: GroupRideSummary['joinPolicy'];
|
||||
scheduledDate: string;
|
||||
scheduledTime: string;
|
||||
distance: string;
|
||||
meetingPoint: string;
|
||||
routeSnippet?: string;
|
||||
participantLimit: number;
|
||||
}
|
||||
|
||||
|
|
@ -63,13 +69,15 @@ export interface EditRideFormData {
|
|||
region: string;
|
||||
description: string;
|
||||
paceClassification: GroupRideSummary['paceClassification'];
|
||||
discipline?: GroupRideSummary['discipline'];
|
||||
formationProfile: string;
|
||||
joinPolicy: GroupRideSummary['joinPolicy'];
|
||||
scheduledDate: string;
|
||||
scheduledTime: string;
|
||||
distance: string;
|
||||
meetingPoint: string;
|
||||
routeSnippet?: string;
|
||||
participantLimit: number;
|
||||
status: 'scheduled' | 'assembling' | 'underway' | 'completed' | 'canceled';
|
||||
status: 'draft' | 'scheduled' | 'upcoming' | 'active' | 'underway' | 'completed' | 'canceled';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,17 @@ import { GroupRidesDirectory } from '../components/group-rides/group-rides-direc
|
|||
import { INITIAL_GROUP_RIDES } from '../components/group-rides/fixtures';
|
||||
import { SiteHeaderClient } from '../components/site-header-client';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace, back: vi.fn() }),
|
||||
}));
|
||||
|
||||
import GroupRidesManageRoute from '../app/group-rides/manage/page';
|
||||
import GroupRidesShowcaseRoute from '../app/group-rides/showcase/page';
|
||||
|
||||
describe('Group Rides Interactive Page & Components', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -276,19 +282,35 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
||||
expect(screen.getByText(/Never lose a wingman/i)).toBeDefined();
|
||||
|
||||
// Feature highlights check
|
||||
// Feature highlights check (6-feature grid)
|
||||
expect(screen.getByText(/Staggered Paceline Warnings/i)).toBeDefined();
|
||||
expect(screen.getByText(/Crash & Separation Alerts/i)).toBeDefined();
|
||||
expect(screen.getByText(/Swarm Broadcast & Crash Separation/i)).toBeDefined();
|
||||
expect(screen.getByText(/Peer-to-Peer Mesh Sync/i)).toBeDefined();
|
||||
expect(screen.getByText(/3D Multi-Bike Replay/i)).toBeDefined();
|
||||
expect(screen.getByText(/3D Multi-Bike Replay & Debrief/i)).toBeDefined();
|
||||
expect(screen.getByText(/Multi-Band GNSS Precision/i)).toBeDefined();
|
||||
expect(screen.getByText(/Offline Cell-Tower Fallback/i)).toBeDefined();
|
||||
|
||||
// Hardware strip
|
||||
expect(screen.getByText(/Cardo DMC & Sena Mesh/i)).toBeDefined();
|
||||
expect(screen.getAllByText(/Bluetooth LE/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Android Auto \/ CarPlay/i).length).toBeGreaterThan(0);
|
||||
|
||||
// CTAs check
|
||||
expect(screen.getByRole('link', { name: /get started free/i })).toBeDefined();
|
||||
expect(screen.getByRole('link', { name: /sign in to join runs/i })).toBeDefined();
|
||||
expect(screen.getByRole('link', { name: /download app/i })).toBeDefined();
|
||||
expect(screen.getAllByRole('link', { name: /sign in to join pack/i }).length).toBeGreaterThan(0);
|
||||
|
||||
// Community preview
|
||||
expect(screen.getByText(/Sample Pack Formations/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders logged-in banner when an authenticated user visits showcase', () => {
|
||||
render(<GroupRidesShowcase isLoggedIn={true} userEmail="rider@motorsignal.app" />);
|
||||
expect(screen.getByText(/You're viewing the public showcase\./i)).toBeDefined();
|
||||
const returnBtn = screen.getByRole('link', { name: /return to group rides console/i });
|
||||
expect(returnBtn).toBeDefined();
|
||||
expect(returnBtn.getAttribute('href')).toBe('/group-rides/manage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupRidesManagement (For Logged In Users - View, Add, Remove, Alter)', () => {
|
||||
|
|
@ -300,12 +322,26 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
expect(screen.getByText(/Group Rides Management/i)).toBeDefined();
|
||||
expect(screen.getByText(/Squad Management & Telemetry Hub/i)).toBeDefined();
|
||||
|
||||
// KPI Stat Cards check
|
||||
expect(screen.getByText(/Active Squads Nearby/i)).toBeDefined();
|
||||
expect(screen.getByText(/Total Miles in Formation/i)).toBeDefined();
|
||||
expect(screen.getByText(/Pack Safety Score/i)).toBeDefined();
|
||||
expect(screen.getByText(/Mesh Node Health/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();
|
||||
|
||||
// Details Modal inspection
|
||||
const detailBtns = screen.getAllByRole('button', { name: /details/i });
|
||||
fireEvent.click(detailBtns[0]);
|
||||
expect(screen.getByRole('dialog', { name: /bay area dawn patrol/i })).toBeDefined();
|
||||
const closeDetailBtn = screen.getByRole('button', { name: /^close$/i });
|
||||
fireEvent.click(closeDetailBtn);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
// 2. ADD: Create a new group ride
|
||||
const createBtn = screen.getByRole('button', { name: /create group ride/i });
|
||||
fireEvent.click(createBtn);
|
||||
|
|
@ -371,17 +407,42 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
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();
|
||||
expect(screen.getByText(/Live Squad Telemetry Stream: Connected/i)).toBeDefined();
|
||||
expect(screen.getByText(/Formation session #CA-84-SW/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full GroupRidesPage Smart Routing', () => {
|
||||
it('renders feature showcase for unauthenticated users', () => {
|
||||
it('renders feature showcase for unauthenticated users', async () => {
|
||||
render(<GroupRidesPage />);
|
||||
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText(/Never lose a wingman/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders group rides management console for authenticated users', () => {
|
||||
const mockUser = { email: 'lead.rider@motorsignal.app', uid: 'test-user-123' } as any;
|
||||
render(<GroupRidesManagement currentUser={mockUser} />);
|
||||
expect(screen.getByText(/Group Rides Management/i)).toBeDefined();
|
||||
expect(screen.getByText(/Squad Management & Telemetry Hub/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupRidesManageRoute (/group-rides/manage)', () => {
|
||||
it('redirects unauthenticated users immediately to /login?redirect=/group-rides/manage', async () => {
|
||||
render(<GroupRidesManageRoute />);
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/login?redirect=/group-rides/manage');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupRidesShowcaseRoute (/group-rides/showcase)', () => {
|
||||
it('always renders showcase for visitors', async () => {
|
||||
render(<GroupRidesShowcaseRoute />);
|
||||
expect(screen.getByText(/Ride in pack formation/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue