feat(website): add club moderation, stats, and group rides management
This commit is contained in:
parent
aced2b5435
commit
368858670d
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue