feat(website): discrete group rides showcase and management architecture lock

This commit is contained in:
Developer 2026-09-25 18:36:54 -04:00
parent 69fefd7b56
commit f20a09addf
10 changed files with 619 additions and 181 deletions

View File

@ -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 &amp; permissions...
</span>
</div>
</div>
);
@ -55,3 +41,4 @@ export default function GroupRidesManageRoute() {
return <GroupRidesManagement currentUser={currentUser} />;
}

View File

@ -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} />;
}

View File

@ -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 &amp; 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">

View File

@ -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',

View File

@ -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 &amp; 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>

View File

@ -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&apos;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 &amp; Separation Alerts
Swarm Broadcast &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View File

@ -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>

View File

@ -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 &amp; 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 &amp; 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 &amp; 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>
);
}

View File

@ -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';
}

View File

@ -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();
});
});
// ============================================================