diff --git a/website/app/globals.css b/website/app/globals.css index 79c5bdc..c58177d 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -30,6 +30,18 @@ --tertiary-container: #b2e1ff; --error: #ffb4ab; --error-container: #93000a; + + /* Stitch Tactical Cockpit Telemetry Tokens */ + --cockpit-void: #070a0f; + --surface-card: #0d131f; + --surface-interactive: #141c2e; + --electric-cyan: #00e5ff; + --electric-blue: #2563eb; + --neon-purple: #8b5cf6; + --slate-muted: #1e293b; + --alert-red: #ef4444; + --hazard-amber: #f59e0b; + --lean-green: #10b981; } * { box-sizing: border-box; } html { background: var(--surface); color: var(--on-surface); } diff --git a/website/components/group-rides/group-rides-management.tsx b/website/components/group-rides/group-rides-management.tsx index 3cde3d9..6ee8d06 100644 --- a/website/components/group-rides/group-rides-management.tsx +++ b/website/components/group-rides/group-rides-management.tsx @@ -1,7 +1,18 @@ 'use client'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import Link from 'next/link'; +import { + collection, + query, + where, + onSnapshot, + doc, + writeBatch, + updateDoc, + deleteDoc, +} from 'firebase/firestore'; +import { firestore } from '../../lib/firebase-client'; import { INITIAL_GROUP_RIDES } from './fixtures'; import { GroupRideSummary } from './types'; import { CreateRideModal } from './create-ride-modal'; @@ -12,10 +23,109 @@ import { LivePackRadar } from './live-pack-radar'; import { RideDetailsModal } from './ride-details-modal'; interface GroupRidesManagementProps { - currentUser?: { email?: string | null; displayName?: string | null } | null; + currentUser?: { uid?: string; email?: string | null; displayName?: string | null } | null; onViewShowcase?: () => void; } +function mapFirestoreDocToSummary( + data: any, + docId: string, + currentUid?: string +): GroupRideSummary { + const isOrganizer = Boolean(currentUid && data.organizerUid === currentUid); + const isParticipant = Boolean( + currentUid && Array.isArray(data.accessUids) && data.accessUids.includes(currentUid) + ); + + let scheduledDate = 'Upcoming'; + let scheduledTime = 'TBD'; + if (data.scheduledStartAt) { + try { + const d = new Date(data.scheduledStartAt); + if (!isNaN(d.getTime())) { + scheduledDate = d.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + scheduledTime = d.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + }); + } + } catch (_) {} + } + + let distance = 'Open Route'; + if (typeof data.routeDistanceMeters === 'number' && data.routeDistanceMeters > 0) { + distance = `${(data.routeDistanceMeters / 1609.34).toFixed(1)} Miles`; + } else if (data.distance) { + distance = String(data.distance); + } + + const meetingPoint = + data.meetingPoint?.displayName || + data.meetingPoint?.address || + 'Designated Rally Point'; + const region = + data.meetingPoint?.address || + data.meetingPoint?.displayName || + data.region || + 'Regional Run'; + + let routeSnippet = data.routeTitle; + if (!routeSnippet && data.destination?.displayName) { + routeSnippet = `${meetingPoint} > ${data.destination.displayName}`; + } + if (!routeSnippet) { + routeSnippet = data.routeSnippet || 'Custom pack route staged on MotorSignal'; + } + + const leadHandle = + data.organizerDisplayName || + data.organizerHandle || + (isOrganizer ? 'You (Organizer)' : 'Pack Marshal'); + const leadAvatarText = ( + data.organizerDisplayName?.[0] || + data.organizerHandle?.[0] || + (isOrganizer ? 'Y' : 'P') + ).toUpperCase(); + + const status = data.status || 'scheduled'; + + let userStatus: GroupRideSummary['userStatus'] = 'none'; + if (isOrganizer || isParticipant) { + userStatus = 'joined'; + } + + return { + id: docId, + title: data.title || 'Untitled Ride', + description: data.description || '', + region, + scheduledDate, + scheduledTime, + paceClassification: data.paceClassification || 'Brisk Pace', + discipline: data.discipline || 'Sport / Canyon', + formationProfile: data.formationProfile || 'Staggered Dual Column [B]', + joinPolicy: data.joinPolicy || 'requestToJoin', + leadHandle, + leadAvatarText, + participantCount: + typeof data.participantCount === 'number' ? data.participantCount : 1, + participantLimit: + typeof data.participantLimit === 'number' ? data.participantLimit : 12, + distance, + meetingPoint, + routeSnippet, + userStatus, + isOrganizer, + status, + rideCode: data.id || docId, + clubName: data.clubName || undefined, + }; +} + type ManagementTab = 'my-rides' | 'discover' | 'radar'; type MyRidesFilter = 'all' | 'organized' | 'joined'; @@ -108,39 +218,247 @@ export function GroupRidesManagement({ return list; }, [rides, paceFilter, searchQuery]); + // Realtime synchronization with Firestore + useEffect(() => { + if (!currentUser?.uid || !firestore) { + return; + } + + let isSubscribed = true; + const ridesMap = new Map(); + + const updateCombinedRides = () => { + if (!isSubscribed) return; + const combined = Array.from(ridesMap.values()); + combined.sort((a, b) => { + if (a.status === 'canceled' && b.status !== 'canceled') return 1; + if (a.status !== 'canceled' && b.status === 'canceled') return -1; + return b.id.localeCompare(a.id); + }); + setRides(combined); + }; + + let unsubOrganized: (() => void) | null = null; + let unsubJoined: (() => void) | null = null; + + try { + const qOrganized = query( + collection(firestore, 'group_rides'), + where('organizerUid', '==', currentUser.uid) + ); + + unsubOrganized = onSnapshot( + qOrganized, + (snapshot) => { + snapshot.docs.forEach((docSnap) => { + ridesMap.set( + docSnap.id, + mapFirestoreDocToSummary(docSnap.data(), docSnap.id, currentUser.uid) + ); + }); + snapshot.docChanges().forEach((change) => { + if (change.type === 'removed') { + ridesMap.delete(change.doc.id); + } + }); + updateCombinedRides(); + }, + (err) => { + console.warn('[GroupRidesManagement] Organized rides snapshot warning:', err); + } + ); + + const qJoined = query( + collection(firestore, 'group_rides'), + where('accessUids', 'array-contains', currentUser.uid) + ); + + unsubJoined = onSnapshot( + qJoined, + (snapshot) => { + snapshot.docs.forEach((docSnap) => { + ridesMap.set( + docSnap.id, + mapFirestoreDocToSummary(docSnap.data(), docSnap.id, currentUser.uid) + ); + }); + snapshot.docChanges().forEach((change) => { + if (change.type === 'removed' && ridesMap.get(change.doc.id)?.isOrganizer === false) { + ridesMap.delete(change.doc.id); + } + }); + updateCombinedRides(); + }, + (err) => { + console.warn('[GroupRidesManagement] Joined rides snapshot warning:', err); + } + ); + } catch (e) { + console.warn('[GroupRidesManagement] Subscription init warning:', e); + } + + return () => { + isSubscribed = false; + if (unsubOrganized) unsubOrganized(); + if (unsubJoined) unsubJoined(); + }; + }, [currentUser?.uid]); + // Handlers for Add, Alter, Remove, Join, Leave - const handleCreateRide = (newRide: GroupRideSummary) => { + const handleCreateRide = async (newRide: GroupRideSummary) => { + const uid = currentUser?.uid; + const now = new Date(); + const rideId = uid + ? `ride_${now.getTime()}_${uid.substring(0, Math.min(6, uid.length))}` + : `MS-${Math.floor(1000 + Math.random() * 9000)}`; + const rideWithOrganizer: GroupRideSummary = { ...newRide, + id: rideId, isOrganizer: true, status: 'scheduled', userStatus: 'joined', - rideCode: `MS-${Math.floor(1000 + Math.random() * 9000)}`, + rideCode: rideId, leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle', leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(), }; + + // Optimistic local update setRides((prev) => [rideWithOrganizer, ...prev]); - showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideWithOrganizer.rideCode}`, 'success'); + showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success'); + + if (uid && firestore) { + try { + const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer'; + const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider'; + + let scheduledStartAt = new Date(now.getTime() + 86400000).toISOString(); + let departureAt = new Date(now.getTime() + 86400000 + 1800000).toISOString(); + + if (newRide.scheduledDate) { + const parsed = new Date(`${newRide.scheduledDate} ${newRide.scheduledTime}`); + if (!isNaN(parsed.getTime()) && parsed.getTime() > now.getTime() - 86400000) { + scheduledStartAt = parsed.toISOString(); + departureAt = new Date(parsed.getTime() + 30 * 60000).toISOString(); + } + } + + const firestoreRideData = { + id: rideId, + organizerUid: uid, + organizerHandle: leadHandle, + organizerDisplayName: leadDisplayName, + title: newRide.title.trim(), + description: newRide.description?.trim() || '', + visibility: 'public', + joinPolicy: newRide.joinPolicy || 'requestToJoin', + status: 'scheduled', + scheduledStartAt, + departureAt, + participantLimit: Number(newRide.participantLimit) || 12, + participantCount: 1, + accessUids: [uid], + meetingPoint: { + latitude: 34.5378, + longitude: -83.9849, + displayName: newRide.meetingPoint || newRide.region || 'Designated Rally Point', + address: newRide.region || '', + locationType: 'meetup', + notes: '', + }, + stops: [], + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + paceClassification: newRide.paceClassification || 'Brisk Pace', + discipline: newRide.discipline || 'Sport / Canyon', + formationProfile: newRide.formationProfile || 'Staggered Dual Column [B]', + routeTitle: newRide.routeSnippet || '', + }; + + const organizerParticipant = { + userId: uid, + role: 'organizer', + status: 'accepted', + riderHandle: leadHandle, + riderDisplayName: leadDisplayName, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + const batch = writeBatch(firestore); + batch.set(doc(firestore, 'group_rides', rideId), firestoreRideData); + batch.set(doc(firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant); + await batch.commit(); + } catch (err: any) { + console.error('Failed to persist group ride to Firestore:', err); + } + } }; - const handleSaveEditedRide = (updatedRide: GroupRideSummary) => { + const handleSaveEditedRide = async (updatedRide: GroupRideSummary) => { + // Optimistic local update setRides((prev) => prev.map((r) => (r.id === updatedRide.id ? updatedRide : r))); showAlert(`Updated parameters for "${updatedRide.title}".`, 'success'); + + if (currentUser?.uid && firestore) { + try { + const now = new Date().toISOString(); + await updateDoc(doc(firestore, 'group_rides', updatedRide.id), { + title: updatedRide.title.trim(), + description: updatedRide.description?.trim() || '', + participantLimit: Number(updatedRide.participantLimit) || 12, + joinPolicy: updatedRide.joinPolicy || 'requestToJoin', + paceClassification: updatedRide.paceClassification || 'Brisk Pace', + discipline: updatedRide.discipline || 'Sport / Canyon', + formationProfile: updatedRide.formationProfile || 'Staggered Dual Column [B]', + routeTitle: updatedRide.routeSnippet || '', + updatedAt: now, + }); + } catch (err: any) { + console.error('Failed to update group ride in Firestore:', err); + } + } }; - const handleDeleteRide = () => { + const handleDeleteRide = async () => { if (!deletingRide) return; const title = deletingRide.title; - setRides((prev) => prev.filter((r) => r.id !== deletingRide.id)); + const rideId = deletingRide.id; setDeletingRide(null); + + // Optimistic local update + setRides((prev) => prev.filter((r) => r.id !== rideId)); showAlert(`Deleted group ride "${title}". Pack telemetry cleared.`, 'info'); + + if (currentUser?.uid && firestore) { + try { + await deleteDoc(doc(firestore, 'group_rides', rideId)); + } catch (err: any) { + console.error('Failed to delete group ride in Firestore:', err); + } + } }; - const handleCancelRide = (ride: GroupRideSummary) => { + const handleCancelRide = async (ride: GroupRideSummary) => { + // Optimistic local update setRides((prev) => prev.map((r) => (r.id === ride.id ? { ...r, status: 'canceled' } : r)) ); showAlert(`Ride "${ride.title}" status changed to Canceled. Participants notified.`, 'info'); + + if (currentUser?.uid && firestore) { + try { + const now = new Date().toISOString(); + await updateDoc(doc(firestore, 'group_rides', ride.id), { + status: 'canceled', + canceledAt: now, + canceledReason: 'Canceled by organizer via MotorSignal Web Console', + updatedAt: now, + }); + } catch (err: any) { + console.error('Failed to cancel group ride in Firestore:', err); + } + } }; const handleLeaveRide = () => {