feat(website): add club moderation, stats, and group rides management

This commit is contained in:
Developer 2026-10-02 11:07:44 -04:00
parent aced2b5435
commit 368858670d
15 changed files with 735 additions and 164 deletions

View File

@ -28,6 +28,15 @@
}
]
},
{
"collectionGroup": "group_rides",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "visibility", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "scheduledStartAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "group_rides",
"queryScope": "COLLECTION",
@ -162,4 +171,4 @@
}
],
"fieldOverrides": []
}
}

View File

@ -0,0 +1,146 @@
import { FieldPath, type QueryDocumentSnapshot } from 'firebase-admin/firestore';
import { NextRequest, NextResponse } from 'next/server';
import { getAdminAuth, getAdminFirestore } from '../../../lib/firebase-admin';
export const dynamic = 'force-dynamic';
const DEFAULT_PAGE_SIZE = 30;
const MAX_PAGE_SIZE = 50;
function isoDate(value: unknown): string | null {
if (typeof value === 'string') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (value && typeof (value as { toDate?: unknown }).toDate === 'function') {
const date = (value as { toDate: () => Date }).toDate();
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
export async function GET(request: NextRequest) {
try {
const authorization = request.headers.get('authorization');
if (!authorization?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
let callerUid: string;
try {
callerUid = (await getAdminAuth().verifyIdToken(authorization.slice(7).trim())).uid;
} catch {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const params = request.nextUrl.searchParams;
const requestedSize = params.get('pageSize');
const pageSize = requestedSize === null ? DEFAULT_PAGE_SIZE : Number(requestedSize);
const cursor = params.get('cursor');
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE ||
(cursor !== null && (!cursor || cursor.length > 64 || /\s/.test(cursor)))) {
return NextResponse.json({ error: 'invalid_request' }, { status: 400 });
}
const db = getAdminFirestore();
const ridesCollection = db.collection('group_rides');
let ridesQuery = ridesCollection
.where('visibility', '==', 'public')
.where('status', 'in', ['scheduled', 'assembling'])
.orderBy('scheduledStartAt', 'asc')
.orderBy(FieldPath.documentId(), 'asc');
if (cursor) {
const cursorDoc = await ridesCollection.doc(cursor).get();
if (!cursorDoc.exists) {
return NextResponse.json({ error: 'invalid_cursor' }, { status: 400 });
}
ridesQuery = ridesQuery.startAfter(cursorDoc);
}
const scanLimit = pageSize * 2;
const rideSnapshot = await ridesQuery.limit(scanLimit).get();
const candidates = rideSnapshot.docs.filter((ride: QueryDocumentSnapshot) => {
const data = ride.data();
const scheduledStartAt = isoDate(data.scheduledStartAt);
return !('clubId' in data) && scheduledStartAt !== null &&
new Date(scheduledStartAt).getTime() > Date.now() &&
typeof data.organizerUid === 'string' && data.organizerUid.length > 0;
});
const callerBlocksSnapshot = await db
.collection('users').doc(callerUid).collection('blocks').get();
const blockedByCaller = new Set(callerBlocksSnapshot.docs.map((blocked) => blocked.id));
const eligible = candidates.filter((ride) => !blockedByCaller.has(ride.data().organizerUid));
const organizersToCheck = [...new Set(eligible
.map((ride) => ride.data().organizerUid as string)
.filter((uid) => uid !== callerUid))];
const reverseBlocks = await Promise.all(organizersToCheck.map(async (organizerUid) => {
const blocked = await db.collection('users').doc(organizerUid)
.collection('blocks').doc(callerUid).get();
return [organizerUid, blocked.exists] as const;
}));
const blockedCallers = new Set(reverseBlocks.filter(([, blocked]) => blocked).map(([uid]) => uid));
const visible = eligible.filter((ride) => !blockedCallers.has(ride.data().organizerUid));
const page = visible.slice(0, pageSize);
const participantSnapshots = await Promise.all(page.map((ride) =>
ride.ref.collection('participants').doc(callerUid).get()));
const rides = page.map((ride, index) => {
const data = ride.data();
const meetingPoint = data.meetingPoint && typeof data.meetingPoint === 'object'
? data.meetingPoint as Record<string, unknown>
: {};
const participantStatus = participantSnapshots[index].data()?.status;
const isOrganizer = data.organizerUid === callerUid;
const userStatus = isOrganizer || participantStatus === 'accepted' || participantStatus === 'invited'
? 'joined'
: participantStatus === 'requested' || participantStatus === 'waitlisted'
? participantStatus
: 'none';
const scheduledStartAt = isoDate(data.scheduledStartAt)!;
return {
id: ride.id,
title: typeof data.title === 'string' ? data.title : 'Group ride',
description: typeof data.description === 'string' ? data.description : '',
region: typeof meetingPoint.address === 'string' ? meetingPoint.address
: typeof meetingPoint.displayName === 'string' ? meetingPoint.displayName : 'Regional ride',
meetingPoint: typeof meetingPoint.displayName === 'string' ? meetingPoint.displayName
: typeof meetingPoint.address === 'string' ? meetingPoint.address : 'Rally point shared by organizer',
scheduledStartAt,
paceClassification: typeof data.paceClassification === 'string' ? data.paceClassification : 'Brisk Pace',
discipline: typeof data.discipline === 'string' ? data.discipline : 'Sport / Canyon',
formationProfile: typeof data.formationProfile === 'string' ? data.formationProfile : 'Staggered Dual Column [B]',
joinPolicy: data.joinPolicy === 'open' || data.joinPolicy === 'inviteOnly' ? data.joinPolicy : 'requestToJoin',
leadHandle: typeof data.organizerDisplayName === 'string' ? data.organizerDisplayName
: typeof data.organizerHandle === 'string' ? data.organizerHandle : 'Rider',
leadAvatarText: String(data.organizerDisplayName || data.organizerHandle || 'R').slice(0, 1).toUpperCase(),
participantCount: typeof data.participantCount === 'number' ? data.participantCount : 1,
participantLimit: typeof data.participantLimit === 'number' ? data.participantLimit : 12,
distance: typeof data.routeDistanceMeters === 'number' && data.routeDistanceMeters > 0
? `${(data.routeDistanceMeters / 1609.34).toFixed(1)} Miles`
: typeof data.distance === 'string' ? data.distance : 'Open Route',
routeSnippet: typeof data.routeTitle === 'string' ? data.routeTitle : '',
userStatus,
isOrganizer,
status: data.status,
rideCode: ride.id,
};
});
const hasMoreCandidates = rideSnapshot.docs.length === scanLimit;
const hasMoreVisible = visible.length > pageSize;
const nextCursor = hasMoreVisible
? page[page.length - 1]?.id
: hasMoreCandidates ? rideSnapshot.docs[rideSnapshot.docs.length - 1]?.id : undefined;
return NextResponse.json(
{ rides, nextCursor: nextCursor ?? null },
{ headers: { 'Cache-Control': 'no-store, max-age=0' } }
);
} catch (error) {
console.error('[GROUP_RIDE_DISCOVERY] Failed to list public rides:', error);
return NextResponse.json({ error: 'server_error' }, { status: 500 });
}
}

View File

@ -0,0 +1,8 @@
'use client';
import { use } from 'react';
import { FounderModerationPage } from '../../../../../components/founder-portal/founder-moderation-page';
export default function ModerationPage({ params }: { params: Promise<{ clubId: string }> }) {
return <FounderModerationPage clubId={use(params).clubId} />;
}

View File

@ -0,0 +1,8 @@
'use client';
import { use } from 'react';
import { FounderStatsPage } from '../../../../../components/founder-portal/founder-stats-page';
export default function StatsPage({ params }: { params: Promise<{ clubId: string }> }) {
return <FounderStatsPage clubId={use(params).clubId} />;
}

View File

@ -139,7 +139,7 @@ export function AttentionSection({
</div>
</div>
{/* Card 2: Moderation Flag / Wall Posts (Preserving deferred backend architecture) */}
{/* Card 2: Report intake is not yet available. */}
<div className="rounded-xl bg-surface-container/60 border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between shadow-md">
<div>
<div className="flex items-center justify-between pb-2.5 border-b border-brand-border/40">
@ -153,20 +153,20 @@ export function AttentionSection({
</div>
<p className="font-body text-xs text-slate-400 mt-2 mb-3">
Club Wall moderation and content flagging module.
Club Wall posts can be reviewed in Moderation. Post reports are not collected yet.
</p>
<EmptyState
icon="verified"
title="No Reported Posts"
description="No content flags pending. Club Wall moderation backend is scheduled for future release."
title="Post Reports Unavailable"
description="Report intake is not enabled. Review recent posts directly in Moderation."
className="py-4"
/>
</div>
<div className="mt-3 pt-2 border-t border-brand-border/40 flex items-center justify-between text-slate-400 font-mono text-[10px]">
<span>Moderation Queue</span>
<span>Cleared</span>
<span>Unavailable</span>
</div>
</div>

View File

@ -0,0 +1,95 @@
'use client';
import { useEffect, useState } from 'react';
import { onAuthStateChanged } from 'firebase/auth';
import { useRouter, useSearchParams } from 'next/navigation';
import { firebaseAuth } from '../../lib/firebase-client';
import { fetchFounderClubContext, sanitizePortalError, type FounderClubContext } from '../../lib/club-data';
import { FounderPortalShell } from './founder-portal-shell';
import { FounderApplications } from './founder-applications';
import { FounderWall } from './founder-wall';
import { FounderMembers } from './founder-members';
import { OverviewSkeleton } from './skeleton-state';
import { SafeErrorState } from './safe-error-state';
type ReviewArea = 'applications' | 'wall' | 'members';
export function FounderModerationPage({ clubId }: { clubId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo';
const [context, setContext] = useState<FounderClubContext | null>(null);
const [area, setArea] = useState<ReviewArea>('applications');
const [loading, setLoading] = useState(true);
const [access, setAccess] = useState<'sign-in' | 'restricted' | null>(null);
const [error, setError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
let generation = 0;
let disposed = false;
const unsubscribe = onAuthStateChanged(firebaseAuth, async (user) => {
const current = ++generation;
setContext(null); setPendingCount(0); setLoading(true); setAccess(null); setError(null);
if (!demo && (!user || user.isAnonymous)) {
setAccess('sign-in'); setLoading(false); return;
}
try {
const resolved = await fetchFounderClubContext(clubId, user?.uid, { allowDemoPreview: demo });
if (disposed || current !== generation) return;
if (!resolved.permissions.canManageSettings && !resolved.isDemoFixture) setAccess('restricted');
else setContext(resolved);
} catch (err) {
if (!disposed && current === generation) setError(sanitizePortalError(err, 'Club moderation could not be loaded.'));
} finally {
if (!disposed && current === generation) setLoading(false);
}
});
return () => { disposed = true; generation++; unsubscribe(); };
}, [clubId, demo, retry]);
if (loading) return <div className="min-h-screen bg-brand-bg p-6"><OverviewSkeleton /></div>;
if (access) return <SafeErrorState title={access === 'sign-in' ? 'Authentication Required' : 'Management Access Restricted'}
message={access === 'sign-in' ? 'Sign in to review club content.' : 'Only club Owners and Admins can moderate this club.'}
actionLabel={access === 'sign-in' ? 'Sign In' : 'Return to Clubs'}
onAction={() => router.push(access === 'sign-in' ? '/login' : '/clubs')} />;
if (error || !context) return <SafeErrorState title="Club moderation unavailable"
message={error || undefined} onRetry={() => setRetry((value) => value + 1)}
actionLabel="Return to Clubs" onAction={() => router.push('/clubs')} />;
return <FounderPortalShell club={context.club} permissions={context.permissions}
activeNav="moderation" pendingApplicationsCount={pendingCount}>
<div className="space-y-6">
<div>
<p className="font-mono text-xs uppercase tracking-wider text-brand-cyan">Club management</p>
<h1 className="font-headline text-3xl font-bold text-on-surface">Moderation</h1>
<p className="mt-2 text-sm text-on-surface-variant">Review membership requests, Wall posts, and member access.</p>
</div>
<p className="rounded-xl border border-brand-border bg-surface-container-low p-4 text-sm text-on-surface-variant">
Post reports are not collected yet. Choose Wall posts to review recent content.
</p>
<div role="tablist" aria-label="Moderation areas" className="flex flex-wrap gap-2">
{([
['applications', 'Applications'],
['wall', 'Wall posts'],
['members', 'Members'],
] as const).map(([id, label]) => <button key={id} type="button" role="tab"
id={`moderation-${id}`} aria-controls="moderation-panel" aria-selected={area === id}
onClick={() => setArea(id)}
className={`rounded-xl border px-4 py-2 text-sm font-semibold ${area === id
? 'border-brand-cyan bg-brand-cyan/15 text-brand-cyan'
: 'border-brand-border bg-surface-container text-on-surface-variant hover:text-on-surface'}`}>
{label}
</button>)}
</div>
<div id="moderation-panel" role="tabpanel" aria-labelledby={`moderation-${area}`}>
{area === 'applications' && <FounderApplications club={context.club} permissions={context.permissions}
callerUid={context.currentUser.uid} isDemoFixture={context.isDemoFixture}
onApplicationsCountChange={setPendingCount} />}
{area === 'wall' && <FounderWall context={context} />}
{area === 'members' && <FounderMembers context={context} />}
</div>
</div>
</FounderPortalShell>;
}

View File

@ -65,11 +65,11 @@ export function FounderSidebar({
{ id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'wall', label: 'Wall', icon: 'forum', disabled: false },
{ id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'stats', label: 'Stats', icon: 'query_stats', disabled: false },
];
const navItemsBottom = [
{ id: 'moderation', label: 'Moderation', icon: 'gavel', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'moderation', label: 'Moderation', icon: 'gavel', disabled: false },
{ id: 'settings', label: 'Settings', icon: 'settings', disabled: false },
];
@ -78,6 +78,8 @@ export function FounderSidebar({
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'members') return `/clubs/${clubParam}/manage/members${queryParam}`;
if (id === 'wall') return `/clubs/${clubParam}/manage/wall${queryParam}`;
if (id === 'stats') return `/clubs/${clubParam}/manage/stats${queryParam}`;
if (id === 'moderation') return `/clubs/${clubParam}/manage/moderation${queryParam}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`;
if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`;
@ -104,7 +106,7 @@ export function FounderSidebar({
const isSettings = item.id === 'settings';
const isApplications = item.id === 'applications';
const isRides = item.id === 'rides';
const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles' || item.id === 'members' || item.id === 'wall';
const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles' || item.id === 'members' || item.id === 'wall' || item.id === 'stats' || item.id === 'moderation';
if (item.disabled) {
return (

View File

@ -0,0 +1,120 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { onAuthStateChanged } from 'firebase/auth';
import { useRouter, useSearchParams } from 'next/navigation';
import { firebaseAuth } from '../../lib/firebase-client';
import {
fetchClubRides,
fetchFounderClubContext,
sanitizePortalError,
type ClubRidesResult,
type FounderClubContext,
} from '../../lib/club-data';
import { FounderPortalShell } from './founder-portal-shell';
import { MetricCard } from './metric-card';
import { OverviewSkeleton } from './skeleton-state';
import { SafeErrorState } from './safe-error-state';
export function FounderStatsPage({ clubId }: { clubId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo';
const [context, setContext] = useState<FounderClubContext | null>(null);
const [rides, setRides] = useState<ClubRidesResult | null>(null);
const [loading, setLoading] = useState(true);
const [access, setAccess] = useState<'sign-in' | 'restricted' | null>(null);
const [error, setError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
useEffect(() => {
let generation = 0;
let disposed = false;
const unsubscribe = onAuthStateChanged(firebaseAuth, async (user) => {
const current = ++generation;
setContext(null); setRides(null); setLoading(true); setAccess(null); setError(null);
if (!demo && (!user || user.isAnonymous)) {
setAccess('sign-in'); setLoading(false); return;
}
try {
const resolved = await fetchFounderClubContext(clubId, user?.uid, { allowDemoPreview: demo });
if (disposed || current !== generation) return;
if (!resolved.permissions.canManageSettings && !resolved.isDemoFixture) {
setAccess('restricted'); return;
}
const clubRides = await fetchClubRides(resolved.club.id, user?.uid, { allowDemoPreview: demo });
if (disposed || current !== generation) return;
setContext(resolved);
setRides(clubRides);
} catch (err) {
if (!disposed && current === generation) setError(sanitizePortalError(err, 'Club stats could not be loaded.'));
} finally {
if (!disposed && current === generation) setLoading(false);
}
});
return () => { disposed = true; generation++; unsubscribe(); };
}, [clubId, demo, retry]);
if (loading) return <div className="min-h-screen bg-brand-bg p-6"><OverviewSkeleton /></div>;
if (access) return <SafeErrorState title={access === 'sign-in' ? 'Authentication Required' : 'Management Access Restricted'}
message={access === 'sign-in' ? 'Sign in to view club stats.' : 'Only club Owners and Admins can view club stats.'}
actionLabel={access === 'sign-in' ? 'Sign In' : 'Return to Clubs'}
onAction={() => router.push(access === 'sign-in' ? '/login' : '/clubs')} />;
if (error || !context || !rides) return <SafeErrorState title="Club stats unavailable"
message={error || undefined} onRetry={() => setRetry((value) => value + 1)}
actionLabel="Return to Clubs" onAction={() => router.push('/clubs')} />;
const completed = rides.pastRides.filter((ride) => ride.status === 'completed');
const canceled = rides.pastRides.filter((ride) => ride.status === 'canceled');
const confirmedSeats = rides.upcomingRides.reduce((sum, ride) => sum + ride.confirmedCount, 0);
const ridePath = `/clubs/${context.club.slug || context.club.id}/manage/rides${demo ? '?preview=demo' : ''}`;
return <FounderPortalShell club={context.club} permissions={context.permissions} activeNav="stats">
<div className="space-y-7">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="font-mono text-xs uppercase tracking-wider text-brand-cyan">Club overview</p>
<h1 className="font-headline text-3xl font-bold text-on-surface">Stats</h1>
<p className="mt-2 text-sm text-on-surface-variant">Current roster and the rides available in club management.</p>
</div>
<Link href={ridePath} className="rounded-xl border border-outline-variant px-4 py-2 text-sm font-semibold text-brand-cyan hover:bg-surface-container">Manage rides</Link>
</div>
<section aria-label="Club statistics" className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Active members" value={context.club.memberCount} subtextLeft="Club roster count" />
<MetricCard label="Scheduled or active" value={rides.upcomingRides.length} subtextLeft="Loaded club rides" disclaimer="Shows up to 30 rides; this is not an all-time total." />
<MetricCard label="Completed" value={completed.length} subtextLeft="Recently loaded rides" disclaimer="Shows up to 30 recent completed or canceled rides." />
<MetricCard label="Confirmed ride seats" value={confirmedSeats} subtextLeft="Across loaded scheduled and active rides" disclaimer="A rider on two rides counts twice. Shows up to 30 rides." />
</section>
<div className="grid gap-5 lg:grid-cols-2">
<section className="rounded-2xl border border-brand-border/60 bg-surface-container-low p-5" aria-labelledby="ride-status-title">
<h2 id="ride-status-title" className="font-headline text-lg font-semibold text-on-surface">Ride status</h2>
<p className="mt-1 text-xs text-on-surface-variant">From the club rides currently loaded in management.</p>
<dl className="mt-5 space-y-3 text-sm">
{[
['Scheduled', rides.upcomingRides.filter((ride) => ride.status === 'scheduled').length],
['Assembling', rides.upcomingRides.filter((ride) => ride.status === 'assembling').length],
['Underway', rides.upcomingRides.filter((ride) => ride.status === 'underway').length],
['Completed', completed.length],
['Canceled', canceled.length],
['Unclassified', [...rides.upcomingRides, ...rides.pastRides].filter((ride) => !ride.status).length],
].map(([label, count]) => <div key={label} className="flex justify-between border-b border-brand-border/40 pb-2">
<dt className="text-on-surface-variant">{label}</dt><dd className="font-mono font-semibold text-on-surface">{count}</dd>
</div>)}
</dl>
</section>
<section className="rounded-2xl border border-brand-border/60 bg-surface-container-low p-5" aria-labelledby="recent-rides-title">
<h2 id="recent-rides-title" className="font-headline text-lg font-semibold text-on-surface">Recent completed rides</h2>
{completed.length === 0 ? <p className="mt-5 text-sm text-on-surface-variant">No completed rides in the recent club ride list.</p>
: <ul className="mt-4 space-y-3">{completed.slice(0, 5).map((ride) => <li key={ride.id} className="flex justify-between gap-4 border-b border-brand-border/40 pb-3 text-sm">
<span className="min-w-0 truncate text-on-surface">{ride.title}</span>
<span className="shrink-0 font-mono text-on-surface-variant">{ride.confirmedCount} seats</span>
</li>)}</ul>}
</section>
</div>
</div>
</FounderPortalShell>;
}

View File

@ -145,6 +145,18 @@ export function FounderTopBar({
<span data-breadcrumb="RIDES" className="text-brand-cyan font-semibold truncate">Rides</span>
</>
)}
{activeNav === 'stats' && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">Stats</span>
</>
)}
{activeNav === 'moderation' && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">Moderation</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant shrink-0">/</span>

View File

@ -11,6 +11,12 @@ import {
writeBatch,
updateDoc,
deleteDoc,
setDoc,
arrayUnion,
arrayRemove,
increment,
serverTimestamp,
getDoc,
} from 'firebase/firestore';
import { firestore } from '../../lib/firebase-client';
import { INITIAL_GROUP_RIDES } from './fixtures';
@ -23,7 +29,7 @@ import { LivePackRadar } from './live-pack-radar';
import { RideDetailsModal } from './ride-details-modal';
interface GroupRidesManagementProps {
currentUser?: { uid?: string; email?: string | null; displayName?: string | null } | null;
currentUser?: { uid?: string; email?: string | null; displayName?: string | null; getIdToken?: () => Promise<string> } | null;
onViewShowcase?: () => void;
}
@ -134,6 +140,7 @@ export function GroupRidesManagement({
onViewShowcase,
}: GroupRidesManagementProps) {
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
const [communityRides, setCommunityRides] = useState<GroupRideSummary[]>([]);
const [activeTab, setActiveTab] = useState<ManagementTab>('my-rides');
const [myRidesFilter, setMyRidesFilter] = useState<MyRidesFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
@ -194,7 +201,7 @@ export function GroupRidesManagement({
// Discover Rides filtered list
const discoverRides = useMemo(() => {
let list = rides;
let list = communityRides;
if (paceFilter !== 'all') {
const pf = paceFilter.toLowerCase();
list = list.filter((r) => {
@ -216,7 +223,38 @@ export function GroupRidesManagement({
);
}
return list;
}, [rides, paceFilter, searchQuery]);
}, [communityRides, paceFilter, searchQuery]);
useEffect(() => {
let active = true;
const loadCommunityRides = async () => {
if (!currentUser?.getIdToken) return;
try {
const token = await currentUser.getIdToken();
const response = await fetch('/api/group-rides?pageSize=50', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!response.ok) throw new Error('Could not load community rides.');
const payload = await response.json();
if (active && Array.isArray(payload.rides)) {
setCommunityRides(payload.rides.map((ride: any) => {
const date = new Date(ride.scheduledStartAt);
return {
...ride,
scheduledDate: date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }),
scheduledTime: date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }),
} as GroupRideSummary;
}));
}
} catch (error) {
console.warn('[GroupRidesManagement] Community rides load failed:', error);
if (active) showAlert('Community rides could not be loaded. Please try again.', 'error');
}
};
void loadCommunityRides();
return () => { active = false; };
}, [currentUser?.uid]);
// Realtime synchronization with Firestore
useEffect(() => {
@ -461,8 +499,37 @@ export function GroupRidesManagement({
}
};
const handleLeaveRide = () => {
const handleLeaveRide = async () => {
if (!leavingRide) return;
const ride = leavingRide;
const uid = currentUser?.uid;
if (!uid || !firestore) {
showAlert('Sign in to leave a group ride.', 'error');
return;
}
try {
const rideRef = doc(firestore, 'group_rides', ride.id);
const participantRef = doc(rideRef, 'participants', uid);
if (ride.userStatus === 'requested') {
await updateDoc(participantRef, { status: 'left', updatedAt: serverTimestamp() });
} else {
const snapshot = await getDoc(rideRef);
const accessUids = snapshot.data()?.accessUids;
if (!Array.isArray(accessUids) || !accessUids.includes(uid)) throw new Error('Ride membership is out of date.');
const batch = writeBatch(firestore);
batch.update(participantRef, { status: 'left', updatedAt: serverTimestamp() });
batch.update(rideRef, {
accessUids: arrayRemove(uid),
participantCount: increment(-1),
updatedAt: serverTimestamp(),
});
await batch.commit();
}
} catch (error) {
console.error('Failed to leave group ride:', error);
showAlert('Could not leave this ride. Refresh and try again.', 'error');
return;
}
const title = leavingRide.title;
setRides((prev) =>
prev.map((r) => {
@ -478,26 +545,65 @@ export function GroupRidesManagement({
showAlert(`Left pack for "${title}". Slot relinquished.`, 'info');
};
const handleRequestSlot = (rideId: string) => {
setRides((prev) =>
prev.map((r) => {
if (r.id !== rideId) return r;
if (r.participantCount >= r.participantLimit) {
showAlert(`Added to waitlist for "${r.title}".`, 'info');
return { ...r, userStatus: 'waitlisted' };
}
showAlert(`Joined pack for "${r.title}"! Telemetry stream linked.`, 'success');
return {
...r,
userStatus: 'joined',
participantCount: r.participantCount + 1,
};
})
);
const handleRequestSlot = async (rideId: string): Promise<'joined' | 'requested' | null> => {
const ride = [...communityRides, ...rides].find((item) => item.id === rideId);
const uid = currentUser?.uid;
if (!ride || !uid || !firestore) {
showAlert('Sign in to join a group ride.', 'error');
return null;
}
if (ride.userStatus === 'joined' || ride.userStatus === 'requested') return ride.userStatus;
if (ride.joinPolicy === 'inviteOnly') {
showAlert('This ride is invite-only. Ask the organizer for an invitation.', 'info');
return null;
}
if (ride.participantCount >= ride.participantLimit) {
showAlert('This ride is full. Its waitlist is not available yet.', 'info');
return null;
}
try {
const rideRef = doc(firestore, 'group_rides', ride.id);
const participantRef = doc(rideRef, 'participants', uid);
const member = {
userId: uid,
role: 'participant',
status: ride.joinPolicy === 'open' ? 'accepted' : 'requested',
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
};
if (ride.joinPolicy === 'open') {
const batch = writeBatch(firestore);
batch.set(participantRef, member);
batch.update(rideRef, {
accessUids: arrayUnion(uid),
participantCount: increment(1),
updatedAt: serverTimestamp(),
});
await batch.commit();
} else {
await setDoc(participantRef, member);
}
const userStatus = ride.joinPolicy === 'open' ? 'joined' : 'requested';
setCommunityRides((prev) => prev.map((item) => item.id === rideId ? {
...item,
userStatus,
participantCount: item.participantCount + (userStatus === 'joined' ? 1 : 0),
} : item));
showAlert(userStatus === 'joined' ? `Joined "${ride.title}".` : `Join request sent for "${ride.title}".`, 'success');
return userStatus;
} catch (error) {
console.error('Failed to join group ride:', error);
showAlert('Could not join this ride. It may have filled; refresh and try again.', 'error');
return null;
}
};
const handleJoinSuccess = (code: string, rideTitle: string) => {
showAlert(`Synchronized telemetry with "${rideTitle}" (Code: ${code})!`, 'success');
const handleJoinSuccess = async (code: string) => {
const ride = communityRides.find((item) => item.id.toUpperCase() === code.toUpperCase());
if (!ride) throw new Error('No public ride was found for that code.');
const status = await handleRequestSlot(ride.id);
if (!status) throw new Error('This ride could not be joined.');
return { title: ride.title, lead: ride.leadHandle, status };
};
const copyRideCode = (code?: string) => {
@ -705,7 +811,7 @@ export function GroupRidesManagement({
}`}
>
<span className="material-symbols-outlined text-[18px]">explore</span>
<span>Explore Community Rides ({rides.length})</span>
<span>Explore Community Rides ({communityRides.length})</span>
</button>
<button
type="button"
@ -1083,6 +1189,7 @@ export function GroupRidesManagement({
{discoverRides.map((ride) => {
const isJoined = ride.userStatus === 'joined' || ride.isOrganizer;
const isFull = ride.participantCount >= ride.participantLimit;
const isRequested = ride.userStatus === 'requested';
return (
<div
@ -1157,14 +1264,12 @@ export function GroupRidesManagement({
<span className="material-symbols-outlined text-[16px]">check_circle</span>
<span>{ride.isOrganizer ? 'Organized by You' : 'Slot Confirmed'}</span>
</div>
) : isRequested ? (
<div className="flex-1 py-2 rounded-xl bg-surface-container text-on-surface-variant text-xs font-semibold text-center">Request Pending</div>
) : isFull ? (
<button
type="button"
onClick={() => handleRequestSlot(ride.id)}
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>
<button type="button" disabled className="flex-1 py-2 rounded-xl bg-surface-container text-on-surface-variant text-xs font-semibold cursor-not-allowed">Ride Full</button>
) : ride.joinPolicy === 'inviteOnly' ? (
<button type="button" disabled className="flex-1 py-2 rounded-xl bg-surface-container text-on-surface-variant text-xs font-semibold cursor-not-allowed">Invite Only</button>
) : (
<button
type="button"

View File

@ -5,14 +5,13 @@ import React, { useState, useEffect } from 'react';
interface JoinRideModalProps {
isOpen: boolean;
onClose: () => void;
onJoinSuccess: (code: string, rideTitle: string) => void;
onJoinSuccess: (code: string) => Promise<{ title: string; lead: string; status: 'joined' | 'requested' }>;
}
export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalProps) {
const [code, setCode] = useState('');
const [isCameraActive, setIsCameraActive] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successInfo, setSuccessInfo] = useState<{ title: string; lead: string } | null>(null);
const [successInfo, setSuccessInfo] = useState<{ title: string; lead: string; status: 'joined' | 'requested' } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset state when modal is opened/closed
@ -21,7 +20,6 @@ export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalP
setCode('');
setErrorMsg(null);
setSuccessInfo(null);
setIsCameraActive(false);
setIsSubmitting(false);
}
}, [isOpen]);
@ -45,7 +43,7 @@ export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalP
if (errorMsg) setErrorMsg(null);
};
const processCode = (rideCode: string) => {
const processCode = async (rideCode: string) => {
const cleanCode = rideCode.trim().toUpperCase();
if (!cleanCode || cleanCode.length < 4) {
setErrorMsg('Please enter a valid ride code (at least 4 characters).');
@ -54,40 +52,18 @@ export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalP
setIsSubmitting(true);
setErrorMsg(null);
// Simulated lookup
setTimeout(() => {
try {
setSuccessInfo(await onJoinSuccess(cleanCode));
} catch (error) {
setErrorMsg(error instanceof Error ? error.message : 'Could not join this ride.');
} finally {
setIsSubmitting(false);
let resolvedTitle = 'Squad Formation Ride';
let resolvedLead = 'ApexLead';
if (cleanCode.includes('APEX') || cleanCode === 'CA-84' || cleanCode === 'APEX84') {
resolvedTitle = 'Bay Area Dawn Patrol';
resolvedLead = 'ApexLead';
} else if (cleanCode.includes('MALIBU') || cleanCode === 'MALIBU7') {
resolvedTitle = 'Malibu Canyons Fast Pace';
resolvedLead = 'Dan_KTM';
} else if (cleanCode.includes('DRAGON') || cleanCode === 'DRAGON318') {
resolvedTitle = 'Tail of the Dragon Run';
resolvedLead = 'Sweep_Marshal';
} else {
resolvedTitle = `Squad Run #${cleanCode}`;
resolvedLead = 'Pack Marshal';
}
setSuccessInfo({ title: resolvedTitle, lead: resolvedLead });
onJoinSuccess(cleanCode, resolvedTitle);
}, 400);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
processCode(code);
};
const handleSimulateScan = () => {
setCode('APEX84');
processCode('APEX84');
void processCode(code);
};
return (
@ -127,27 +103,27 @@ export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalP
<span className="material-symbols-outlined text-[32px]">check_circle</span>
</div>
<span className="font-label-caps text-label-caps text-secondary-fixed mb-1">
Telemetry Linked
{successInfo.status === 'joined' ? 'Ride Joined' : 'Request Sent'}
</span>
<h4 className="font-headline-md text-headline-md text-on-surface mb-1">
{successInfo.title}
</h4>
<p className="font-body-sm text-body-sm text-on-surface-variant mb-6">
Lead Marshal: <span className="text-on-surface font-bold">{successInfo.lead}</span>.
Your telemetry stream is now synchronized with this pack.
Ride lead: <span className="text-on-surface font-bold">{successInfo.lead}</span>.
{successInfo.status === 'joined' ? ' You are on the rider list.' : ' The organizer must approve your request.'}
</p>
<button
onClick={onClose}
className="w-full py-3 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all"
>
Open Pack Radar
Done
</button>
</div>
) : (
/* Input / Scanner Flow */
<form onSubmit={handleSubmit} className="flex flex-col">
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-md">
Enter the encrypted 6-character ride code generated by your Lead Pace Setter or scan their cockpit radar QR display.
Enter the ride code to find a public group ride and join or request a slot.
</p>
{/* Error Message */}
@ -173,60 +149,6 @@ export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalP
/>
</div>
{/* Divider */}
<div className="flex items-center gap-space-md text-on-surface-variant font-code-mono text-code-mono justify-center text-xs">
<span className="h-px bg-outline-variant flex-1"></span>
<span>Or Scan Optical QR</span>
<span className="h-px bg-outline-variant flex-1"></span>
</div>
{/* Optical Lens / Camera Simulation */}
{isCameraActive ? (
<div
className="relative bg-black rounded-xl h-44 overflow-hidden border border-primary-container flex flex-col items-center justify-center p-3"
data-testid="radar-lens-viewfinder"
>
{/* Viewfinder crosshairs */}
<div className="absolute inset-4 border border-secondary-fixed/40 rounded pointer-events-none">
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-secondary-fixed"></div>
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-secondary-fixed"></div>
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-secondary-fixed"></div>
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-secondary-fixed"></div>
{/* Animated scanline */}
<div className="w-full h-0.5 bg-gradient-to-r from-transparent via-primary-container to-transparent animate-pulse mt-12"></div>
</div>
<span className="font-code-mono text-xs text-secondary-fixed mb-2 relative z-10">
Radar Optical Lens Active
</span>
<button
type="button"
onClick={handleSimulateScan}
className="relative z-10 px-3 py-1.5 rounded-lg bg-primary-container text-on-primary-container font-code-mono text-xs hover:bg-primary-fixed transition-colors"
>
Simulate QR Scan (APEX84)
</button>
<button
type="button"
onClick={() => setIsCameraActive(false)}
className="mt-2 text-on-surface-variant hover:text-on-surface text-xs font-code-mono underline"
>
Switch back to manual entry
</button>
</div>
) : (
<div
onClick={() => setIsCameraActive(true)}
className="bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 h-32 rounded-xl flex flex-col items-center justify-center gap-2 text-on-surface-variant hover:text-primary transition-all cursor-pointer group"
data-testid="activate-radar-lens-button"
>
<span className="material-symbols-outlined text-[32px] group-hover:scale-110 transition-transform text-primary-fixed-dim">
photo_camera
</span>
<span className="font-code-mono text-code-mono">Activate Radar Optical Lens</span>
</div>
)}
</div>
{/* Submit Button */}

View File

@ -180,6 +180,8 @@ describe('Direct Founder Portal route authorization', () => {
render(<FounderSidebar club={context.club} permissions={context.permissions} activeNav="members" />);
expect(screen.getByRole('link', { name: /Members/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/members');
expect(screen.getByRole('link', { name: /Wall/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/wall');
expect(screen.getByRole('link', { name: /Stats/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/stats');
expect(screen.getByRole('link', { name: /Moderation/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/moderation');
});
it.each(['members', 'wall'] as const)('denies signed-out users before querying %s data', async (section) => {
mocks.auth.mockImplementation((_auth, deliver) => { deliver(null); return vi.fn(); });

View File

@ -0,0 +1,75 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
const mocks = vi.hoisted(() => ({
user: { uid: 'owner', isAnonymous: false } as { uid: string; isAnonymous: boolean } | null,
auth: vi.fn(),
context: vi.fn(),
}));
vi.mock('firebase/auth', () => ({ onAuthStateChanged: mocks.auth }));
vi.mock('../lib/firebase-client', () => ({ firebaseAuth: {} }));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('../lib/club-data', () => ({
fetchFounderClubContext: mocks.context,
sanitizePortalError: () => 'Could not load club moderation.',
}));
vi.mock('../components/founder-portal/founder-portal-shell', () => ({
FounderPortalShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/founder-portal/founder-applications', () => ({
FounderApplications: () => <div>Real applications review</div>,
}));
vi.mock('../components/founder-portal/founder-wall', () => ({
FounderWall: () => <div>Real Wall moderation</div>,
}));
vi.mock('../components/founder-portal/founder-members', () => ({
FounderMembers: () => <div>Real member access review</div>,
}));
import { FounderModerationPage } from '../components/founder-portal/founder-moderation-page';
const context = {
club: { id: 'ClubID', name: 'Riders Club' },
currentUser: { uid: 'owner', role: 'owner' },
permissions: { role: 'owner', canManageSettings: true },
isDemoFixture: false,
};
describe('Founder Moderation', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.user = { uid: 'owner', isAnonymous: false };
mocks.auth.mockImplementation((_auth, deliver) => { deliver(mocks.user); return vi.fn(); });
mocks.context.mockResolvedValue(context);
});
it('makes existing moderation flows available to an authorized owner', async () => {
render(<FounderModerationPage clubId="club-slug" />);
await screen.findByText('Real applications review');
expect(mocks.context).toHaveBeenCalledWith('club-slug', 'owner', { allowDemoPreview: false });
fireEvent.click(screen.getByRole('tab', { name: 'Wall posts' }));
expect(screen.getByText('Real Wall moderation')).toBeDefined();
fireEvent.click(screen.getByRole('tab', { name: 'Members' }));
expect(screen.getByText('Real member access review')).toBeDefined();
expect(screen.getByText(/Post reports are not collected yet/)).toBeDefined();
});
it('does not load club data when signed out', async () => {
mocks.user = null;
render(<FounderModerationPage clubId="club-slug" />);
await screen.findByText('Authentication Required');
expect(mocks.context).not.toHaveBeenCalled();
});
it('does not render moderation controls for an ordinary member', async () => {
mocks.context.mockResolvedValue({ ...context, permissions: { role: 'member', canManageSettings: false } });
render(<FounderModerationPage clubId="club-slug" />);
await screen.findByText('Management Access Restricted');
expect(screen.queryByRole('tab', { name: 'Wall posts' })).toBeNull();
});
});

View File

@ -0,0 +1,83 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const mocks = vi.hoisted(() => ({
user: { uid: 'owner', isAnonymous: false } as { uid: string; isAnonymous: boolean } | null,
auth: vi.fn(),
context: vi.fn(),
rides: vi.fn(),
}));
vi.mock('firebase/auth', () => ({
onAuthStateChanged: mocks.auth,
}));
vi.mock('../lib/firebase-client', () => ({ firebaseAuth: {} }));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('../lib/club-data', () => ({
fetchFounderClubContext: mocks.context,
fetchClubRides: mocks.rides,
sanitizePortalError: () => 'Could not load club stats.',
}));
vi.mock('../components/founder-portal/founder-portal-shell', () => ({
FounderPortalShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import { FounderStatsPage } from '../components/founder-portal/founder-stats-page';
const context = {
club: { id: 'canonical-club', slug: 'club-slug', name: 'Riders Club', memberCount: 12 },
currentUser: { uid: 'owner', role: 'owner' },
permissions: { role: 'owner', canManageSettings: true },
isDemoFixture: false,
};
describe('Founder Stats', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.user = { uid: 'owner', isAnonymous: false };
mocks.auth.mockImplementation((_auth, deliver) => { deliver(mocks.user); return vi.fn(); });
mocks.context.mockResolvedValue(context);
mocks.rides.mockResolvedValue({
upcomingRides: [
{ id: 'r1', title: 'Saturday Ride', status: 'scheduled', confirmedCount: 3 },
{ id: 'r2', title: 'Sunday Ride', status: 'assembling', confirmedCount: 2 },
],
pastRides: [
{ id: 'r3', title: 'Last Week Ride', status: 'completed', confirmedCount: 4 },
{ id: 'r4', title: 'Rainout', status: 'canceled', confirmedCount: 1 },
{ id: 'r5', title: 'Legacy Ride', status: null, confirmedCount: 1 },
],
});
});
it('derives labeled stats from the authorized club and bounded ride lists', async () => {
render(<FounderStatsPage clubId="club-slug" />);
await screen.findByText('Last Week Ride');
expect(mocks.context).toHaveBeenCalledWith('club-slug', 'owner', { allowDemoPreview: false });
expect(mocks.rides).toHaveBeenCalledWith('canonical-club', 'owner', { allowDemoPreview: false });
expect(screen.getByText('Active members').closest('.glass-card')?.textContent).toContain('12');
expect(screen.getByText('Confirmed ride seats').closest('.glass-card')?.textContent).toContain('5');
expect(screen.getByRole('link', { name: 'Manage rides' }).getAttribute('href')).toBe('/clubs/club-slug/manage/rides');
expect(screen.getAllByText(/up to 30/i).length).toBeGreaterThan(0);
expect(screen.getByText('Unclassified').parentElement?.textContent).toContain('1');
});
it('does not query club data when signed out', async () => {
mocks.user = null;
render(<FounderStatsPage clubId="club-slug" />);
await screen.findByText('Authentication Required');
expect(mocks.context).not.toHaveBeenCalled();
expect(mocks.rides).not.toHaveBeenCalled();
});
it('does not query rides for ordinary members', async () => {
mocks.context.mockResolvedValue({ ...context, permissions: { role: 'member', canManageSettings: false } });
render(<FounderStatsPage clubId="club-slug" />);
await screen.findByText('Management Access Restricted');
expect(mocks.rides).not.toHaveBeenCalled();
});
});

View File

@ -143,28 +143,21 @@ describe('Group Rides Interactive Page & Components', () => {
expect(onJoinSuccess).not.toHaveBeenCalled();
});
it('activates radar lens camera and simulates QR scan successfully', async () => {
it('joins only after the ride code resolves successfully', async () => {
const onClose = vi.fn();
const onJoinSuccess = vi.fn();
render(<JoinRideModal isOpen={true} onClose={onClose} onJoinSuccess={onJoinSuccess} />);
// Click activate optical lens
const lensTrigger = screen.getByTestId('activate-radar-lens-button');
fireEvent.click(lensTrigger);
expect(screen.getByTestId('radar-lens-viewfinder')).toBeDefined();
// Click simulate QR scan
const simulateScanBtn = screen.getByRole('button', { name: /simulate qr scan/i });
fireEvent.click(simulateScanBtn);
await waitFor(() => {
expect(screen.getByText(/Telemetry Linked/i)).toBeDefined();
const onJoinSuccess = vi.fn().mockResolvedValue({
title: 'Saturday Canyon Ride', lead: 'Road Captain', status: 'requested',
});
expect(screen.getByText(/Bay Area Dawn Patrol/i)).toBeDefined();
expect(onJoinSuccess).toHaveBeenCalledWith('APEX84', 'Bay Area Dawn Patrol');
render(<JoinRideModal isOpen={true} onClose={onClose} onJoinSuccess={onJoinSuccess} />);
fireEvent.change(screen.getByLabelText('Ride Code'), { target: { value: 'RIDE-123' } });
fireEvent.click(screen.getByRole('button', { name: /connect squad telemetry/i }));
await waitFor(() => {
expect(screen.getByText(/Request Sent/i)).toBeDefined();
});
expect(screen.getByText(/Saturday Canyon Ride/i)).toBeDefined();
expect(onJoinSuccess).toHaveBeenCalledWith('RIDE-123');
});
});
@ -382,15 +375,7 @@ describe('Group Rides Interactive Page & Components', () => {
expect(screen.getByRole('heading', { name: /delete group ride/i })).toBeDefined();
fireEvent.click(screen.getByRole('button', { name: /delete ride/i }));
// 6. LEAVE: Leave joined pack run with confirmation modal
const leaveBtn = screen.getByRole('button', { name: /leave pack/i });
fireEvent.click(leaveBtn);
expect(screen.getByRole('heading', { name: /leave group ride/i })).toBeDefined();
const leaveConfirmBtns = screen.getAllByRole('button', { name: /leave pack/i });
fireEvent.click(leaveConfirmBtns[leaveConfirmBtns.length - 1]);
// 7. JOIN WITH CODE:
// 6. A nonexistent code must not produce a simulated successful join.
const joinBtn = screen.getByRole('button', { name: /join with code/i });
fireEvent.click(joinBtn);
@ -399,11 +384,10 @@ describe('Group Rides Interactive Page & Components', () => {
fireEvent.change(codeInput, { target: { value: 'APEX84' } });
fireEvent.click(screen.getByRole('button', { name: /connect squad telemetry/i }));
await waitFor(() => {
expect(screen.getByText(/Telemetry Linked/i)).toBeDefined();
});
await waitFor(() => expect(screen.getByText(/No public ride was found/i)).toBeDefined());
fireEvent.click(screen.getByRole('button', { name: /close modal/i }));
// 8. RADAR TAB:
// 7. RADAR TAB:
const radarTabBtn = screen.getByRole('button', { name: /live squad radar cockpit/i });
fireEvent.click(radarTabBtn);