From 368858670d861ddac981f461f823d35c215ecb8a Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 2 Oct 2026 11:07:44 -0400 Subject: [PATCH] feat(website): add club moderation, stats, and group rides management --- firestore.indexes.json | 11 +- website/app/api/group-rides/route.ts | 146 ++++++++++++++++ .../clubs/[clubId]/manage/moderation/page.tsx | 8 + .../app/clubs/[clubId]/manage/stats/page.tsx | 8 + .../founder-portal/attention-card.tsx | 10 +- .../founder-moderation-page.tsx | 95 ++++++++++ .../founder-portal/founder-sidebar.tsx | 8 +- .../founder-portal/founder-stats-page.tsx | 120 +++++++++++++ .../founder-portal/founder-top-bar.tsx | 12 ++ .../group-rides/group-rides-management.tsx | 165 ++++++++++++++---- .../group-rides/join-ride-modal.tsx | 108 ++---------- website/tests/club-community-ui.test.tsx | 2 + website/tests/founder-moderation.test.tsx | 75 ++++++++ website/tests/founder-stats.test.tsx | 83 +++++++++ website/tests/group-rides-page.test.tsx | 48 ++--- 15 files changed, 735 insertions(+), 164 deletions(-) create mode 100644 website/app/api/group-rides/route.ts create mode 100644 website/app/clubs/[clubId]/manage/moderation/page.tsx create mode 100644 website/app/clubs/[clubId]/manage/stats/page.tsx create mode 100644 website/components/founder-portal/founder-moderation-page.tsx create mode 100644 website/components/founder-portal/founder-stats-page.tsx create mode 100644 website/tests/founder-moderation.test.tsx create mode 100644 website/tests/founder-stats.test.tsx diff --git a/firestore.indexes.json b/firestore.indexes.json index 9ed0507..85b192f 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -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": [] -} \ No newline at end of file +} diff --git a/website/app/api/group-rides/route.ts b/website/app/api/group-rides/route.ts new file mode 100644 index 0000000..d3b4402 --- /dev/null +++ b/website/app/api/group-rides/route.ts @@ -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 + : {}; + 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 }); + } +} diff --git a/website/app/clubs/[clubId]/manage/moderation/page.tsx b/website/app/clubs/[clubId]/manage/moderation/page.tsx new file mode 100644 index 0000000..ff3ba10 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/moderation/page.tsx @@ -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 ; +} diff --git a/website/app/clubs/[clubId]/manage/stats/page.tsx b/website/app/clubs/[clubId]/manage/stats/page.tsx new file mode 100644 index 0000000..c563aef --- /dev/null +++ b/website/app/clubs/[clubId]/manage/stats/page.tsx @@ -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 ; +} diff --git a/website/components/founder-portal/attention-card.tsx b/website/components/founder-portal/attention-card.tsx index 8b1a0a0..76088cd 100644 --- a/website/components/founder-portal/attention-card.tsx +++ b/website/components/founder-portal/attention-card.tsx @@ -139,7 +139,7 @@ export function AttentionSection({ - {/* Card 2: Moderation Flag / Wall Posts (Preserving deferred backend architecture) */} + {/* Card 2: Report intake is not yet available. */}
@@ -153,20 +153,20 @@ export function AttentionSection({

- Club Wall moderation and content flagging module. + Club Wall posts can be reviewed in Moderation. Post reports are not collected yet.

Moderation Queue - Cleared + Unavailable
diff --git a/website/components/founder-portal/founder-moderation-page.tsx b/website/components/founder-portal/founder-moderation-page.tsx new file mode 100644 index 0000000..ef68f8f --- /dev/null +++ b/website/components/founder-portal/founder-moderation-page.tsx @@ -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(null); + const [area, setArea] = useState('applications'); + const [loading, setLoading] = useState(true); + const [access, setAccess] = useState<'sign-in' | 'restricted' | null>(null); + const [error, setError] = useState(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
; + if (access) return router.push(access === 'sign-in' ? '/login' : '/clubs')} />; + if (error || !context) return setRetry((value) => value + 1)} + actionLabel="Return to Clubs" onAction={() => router.push('/clubs')} />; + + return +
+
+

Club management

+

Moderation

+

Review membership requests, Wall posts, and member access.

+
+

+ Post reports are not collected yet. Choose Wall posts to review recent content. +

+
+ {([ + ['applications', 'Applications'], + ['wall', 'Wall posts'], + ['members', 'Members'], + ] as const).map(([id, label]) => )} +
+
+ {area === 'applications' && } + {area === 'wall' && } + {area === 'members' && } +
+
+
; +} diff --git a/website/components/founder-portal/founder-sidebar.tsx b/website/components/founder-portal/founder-sidebar.tsx index 7e4b596..614f765 100644 --- a/website/components/founder-portal/founder-sidebar.tsx +++ b/website/components/founder-portal/founder-sidebar.tsx @@ -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 ( diff --git a/website/components/founder-portal/founder-stats-page.tsx b/website/components/founder-portal/founder-stats-page.tsx new file mode 100644 index 0000000..bf6e83a --- /dev/null +++ b/website/components/founder-portal/founder-stats-page.tsx @@ -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(null); + const [rides, setRides] = useState(null); + const [loading, setLoading] = useState(true); + const [access, setAccess] = useState<'sign-in' | 'restricted' | null>(null); + const [error, setError] = useState(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
; + if (access) return router.push(access === 'sign-in' ? '/login' : '/clubs')} />; + if (error || !context || !rides) return 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 +
+
+
+

Club overview

+

Stats

+

Current roster and the rides available in club management.

+
+ Manage rides +
+ +
+ + + + +
+ +
+
+

Ride status

+

From the club rides currently loaded in management.

+
+ {[ + ['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]) =>
+
{label}
{count}
+
)} +
+
+ +
+

Recent completed rides

+ {completed.length === 0 ?

No completed rides in the recent club ride list.

+ :
    {completed.slice(0, 5).map((ride) =>
  • + {ride.title} + {ride.confirmedCount} seats +
  • )}
} +
+
+
+
; +} diff --git a/website/components/founder-portal/founder-top-bar.tsx b/website/components/founder-portal/founder-top-bar.tsx index 9b872f9..15230dc 100644 --- a/website/components/founder-portal/founder-top-bar.tsx +++ b/website/components/founder-portal/founder-top-bar.tsx @@ -145,6 +145,18 @@ export function FounderTopBar({ Rides )} + {activeNav === 'stats' && ( + <> + / + Stats + + )} + {activeNav === 'moderation' && ( + <> + / + Moderation + + )} {activeNav === 'settings' && ( <> / diff --git a/website/components/group-rides/group-rides-management.tsx b/website/components/group-rides/group-rides-management.tsx index 6ee8d06..438ba19 100644 --- a/website/components/group-rides/group-rides-management.tsx +++ b/website/components/group-rides/group-rides-management.tsx @@ -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 } | null; onViewShowcase?: () => void; } @@ -134,6 +140,7 @@ export function GroupRidesManagement({ onViewShowcase, }: GroupRidesManagementProps) { const [rides, setRides] = useState(INITIAL_GROUP_RIDES); + const [communityRides, setCommunityRides] = useState([]); const [activeTab, setActiveTab] = useState('my-rides'); const [myRidesFilter, setMyRidesFilter] = useState('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({ }`} > explore - Explore Community Rides ({rides.length}) + Explore Community Rides ({communityRides.length}) + + ) : ride.joinPolicy === 'inviteOnly' ? ( + ) : ( ) : ( /* Input / Scanner Flow */

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

{/* Error Message */} @@ -173,60 +149,6 @@ export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalP /> - {/* Divider */} -
- - Or Scan Optical QR - -
- - {/* Optical Lens / Camera Simulation */} - {isCameraActive ? ( -
- {/* Viewfinder crosshairs */} -
-
-
-
-
- {/* Animated scanline */} -
-
- - - Radar Optical Lens Active - - - - -
- ) : ( -
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" - > - - photo_camera - - Activate Radar Optical Lens -
- )} {/* Submit Button */} diff --git a/website/tests/club-community-ui.test.tsx b/website/tests/club-community-ui.test.tsx index a565b90..76a898d 100644 --- a/website/tests/club-community-ui.test.tsx +++ b/website/tests/club-community-ui.test.tsx @@ -180,6 +180,8 @@ describe('Direct Founder Portal route authorization', () => { render(); 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(); }); diff --git a/website/tests/founder-moderation.test.tsx b/website/tests/founder-moderation.test.tsx new file mode 100644 index 0000000..9d6f05e --- /dev/null +++ b/website/tests/founder-moderation.test.tsx @@ -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 }) =>
{children}
, +})); +vi.mock('../components/founder-portal/founder-applications', () => ({ + FounderApplications: () =>
Real applications review
, +})); +vi.mock('../components/founder-portal/founder-wall', () => ({ + FounderWall: () =>
Real Wall moderation
, +})); +vi.mock('../components/founder-portal/founder-members', () => ({ + FounderMembers: () =>
Real member access review
, +})); + +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(); + 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(); + 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(); + await screen.findByText('Management Access Restricted'); + expect(screen.queryByRole('tab', { name: 'Wall posts' })).toBeNull(); + }); +}); diff --git a/website/tests/founder-stats.test.tsx b/website/tests/founder-stats.test.tsx new file mode 100644 index 0000000..b60596c --- /dev/null +++ b/website/tests/founder-stats.test.tsx @@ -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 }) =>
{children}
, +})); + +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(); + 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(); + 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(); + await screen.findByText('Management Access Restricted'); + expect(mocks.rides).not.toHaveBeenCalled(); + }); +}); diff --git a/website/tests/group-rides-page.test.tsx b/website/tests/group-rides-page.test.tsx index 9550cea..5b8c287 100644 --- a/website/tests/group-rides-page.test.tsx +++ b/website/tests/group-rides-page.test.tsx @@ -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(); - - // 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(); + 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);