feat(website): apply stitch tactical design tokens and group ride firestore sync
This commit is contained in:
parent
f20a09addf
commit
9bc16329ef
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue