feat(website): apply stitch tactical design tokens and group ride firestore sync

This commit is contained in:
Developer 2026-09-27 07:56:56 -04:00
parent f20a09addf
commit 9bc16329ef
2 changed files with 339 additions and 9 deletions

View File

@ -30,6 +30,18 @@
--tertiary-container: #b2e1ff; --tertiary-container: #b2e1ff;
--error: #ffb4ab; --error: #ffb4ab;
--error-container: #93000a; --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; } * { box-sizing: border-box; }
html { background: var(--surface); color: var(--on-surface); } html { background: var(--surface); color: var(--on-surface); }

View File

@ -1,7 +1,18 @@
'use client'; 'use client';
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import Link from 'next/link'; 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 { INITIAL_GROUP_RIDES } from './fixtures';
import { GroupRideSummary } from './types'; import { GroupRideSummary } from './types';
import { CreateRideModal } from './create-ride-modal'; import { CreateRideModal } from './create-ride-modal';
@ -12,10 +23,109 @@ import { LivePackRadar } from './live-pack-radar';
import { RideDetailsModal } from './ride-details-modal'; import { RideDetailsModal } from './ride-details-modal';
interface GroupRidesManagementProps { interface GroupRidesManagementProps {
currentUser?: { email?: string | null; displayName?: string | null } | null; currentUser?: { uid?: string; email?: string | null; displayName?: string | null } | null;
onViewShowcase?: () => void; 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 ManagementTab = 'my-rides' | 'discover' | 'radar';
type MyRidesFilter = 'all' | 'organized' | 'joined'; type MyRidesFilter = 'all' | 'organized' | 'joined';
@ -108,39 +218,247 @@ export function GroupRidesManagement({
return list; return list;
}, [rides, paceFilter, searchQuery]); }, [rides, paceFilter, searchQuery]);
// Realtime synchronization with Firestore
useEffect(() => {
if (!currentUser?.uid || !firestore) {
return;
}
let isSubscribed = true;
const ridesMap = new Map<string, GroupRideSummary>();
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 // 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 = { const rideWithOrganizer: GroupRideSummary = {
...newRide, ...newRide,
id: rideId,
isOrganizer: true, isOrganizer: true,
status: 'scheduled', status: 'scheduled',
userStatus: 'joined', userStatus: 'joined',
rideCode: `MS-${Math.floor(1000 + Math.random() * 9000)}`, rideCode: rideId,
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle', leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(), leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
}; };
// Optimistic local update
setRides((prev) => [rideWithOrganizer, ...prev]); 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 handleSaveEditedRide = (updatedRide: GroupRideSummary) => { 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 = async (updatedRide: GroupRideSummary) => {
// Optimistic local update
setRides((prev) => prev.map((r) => (r.id === updatedRide.id ? updatedRide : r))); setRides((prev) => prev.map((r) => (r.id === updatedRide.id ? updatedRide : r)));
showAlert(`Updated parameters for "${updatedRide.title}".`, 'success'); 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; if (!deletingRide) return;
const title = deletingRide.title; const title = deletingRide.title;
setRides((prev) => prev.filter((r) => r.id !== deletingRide.id)); const rideId = deletingRide.id;
setDeletingRide(null); setDeletingRide(null);
// Optimistic local update
setRides((prev) => prev.filter((r) => r.id !== rideId));
showAlert(`Deleted group ride "${title}". Pack telemetry cleared.`, 'info'); 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) => setRides((prev) =>
prev.map((r) => (r.id === ride.id ? { ...r, status: 'canceled' } : r)) prev.map((r) => (r.id === ride.id ? { ...r, status: 'canceled' } : r))
); );
showAlert(`Ride "${ride.title}" status changed to Canceled. Participants notified.`, 'info'); 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 = () => { const handleLeaveRide = () => {