diff --git a/website/.env.example b/website/.env.example index 2ee1031..d621134 100644 --- a/website/.env.example +++ b/website/.env.example @@ -1,7 +1,7 @@ # MotorSignal Website Environment Configuration # # Copy this file to .env.local for local development, or configure these -# variables in your deployment environment (Vercel, Docker, VPS, etc.). +# variables in your deployment environment (Vultr VPS, Caddy, Docker, etc.). # # Production Firebase project: motoapp-d22e0 # Staging Firebase project: motorsignal-staging diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index c500117..cf16e95 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -2,9 +2,9 @@ import React, { useEffect, useState } from 'react'; import Link from 'next/link'; -import { onAuthStateChanged, User } from 'firebase/auth'; +import { onAuthStateChanged, multiFactor, User } from 'firebase/auth'; import { firebaseAuth } from '../../lib/firebase-client'; -import { AppCloudData } from '../../components/app-cloud-data'; +import { AppCloudData, type CloudDataStatus } from '../../components/app-cloud-data'; import { FirebaseLink } from '../../components/firebase-link'; import { SignOutButton } from '../../components/sign-out-button'; @@ -22,7 +22,12 @@ export default function AccountPage() { const [syncTrigger, setSyncTrigger] = useState(0); const [isSyncing, setIsSyncing] = useState(false); const [lastSynced, setLastSynced] = useState(null); - const [metrics, setMetrics] = useState({ totalMiles: 12480, rideCount: 184, peakLean: 56.4 }); + const [syncError, setSyncError] = useState(null); + // Truthful metrics & sync states: + // Distinguish 'idle', 'loading', 'unauthenticated', 'ready', 'permission-denied', 'unavailable', and 'error'. + // Never initialize unavailable information in a way that displays as verified zero metrics or 100% synced. + const [metricsStatus, setMetricsStatus] = useState('loading'); + const [metrics, setMetrics] = useState<{ totalMiles: number; rideCount: number; peakLean: number } | null>(null); // Active Device Sessions State const [sessions, setSessions] = useState([]); @@ -38,10 +43,30 @@ export default function AccountPage() { const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => { setUser(currentUser); setAuthChecked(true); + if (!currentUser) { + setMetricsStatus('unauthenticated'); + setMetrics(null); + setSyncError(null); + } else { + setMetricsStatus('loading'); + setMetrics(null); + } }); return () => unsubscribe(); }, []); + // Truthful 2FA state: reflect the user's actual Firebase multi-factor enrollment. + // The web app has no MFA enrollment flow, so this is read-only; enrollment is done in + // the mobile app. Never claim 2FA is enabled unless a factor is actually enrolled. + const twoFactorEnrolled = (() => { + if (!user) return false; + try { + return multiFactor(user).enrolledFactors.length > 0; + } catch { + return false; + } + })(); + async function loadSessions() { setIsLoadingSessions(true); try { @@ -146,7 +171,10 @@ export default function AccountPage() { } function handleQuickSync() { + if (!user) return; setIsSyncing(true); + setSyncError(null); + setMetricsStatus('loading'); setSyncTrigger((prev) => prev + 1); } @@ -224,7 +252,15 @@ export default function AccountPage() { strokeWidth="2" > - {isSyncing ? 'Syncing...' : lastSynced ? `Synced (${lastSynced})` : 'Quick Sync'} + + {isSyncing + ? 'Syncing...' + : syncError + ? 'Sync Failed (Retry)' + : lastSynced + ? `Synced (${lastSynced})` + : 'Quick Sync'} +
@@ -247,46 +283,148 @@ export default function AccountPage() { {/* BEGIN: TelemetryKpiGrid */}
{/* Metric 1: Distance */} -
+
Total Distance Logged -Cloud Aggregate +{metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to view +) : metricsStatus === 'permission-denied' ? ( + Permission denied +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Sync unavailable +) : ( + Cloud Aggregate +)}
-{metrics.totalMiles.toLocaleString()} -miles +{metricsStatus === 'ready' && metrics !== null ? ( + <> + {metrics.totalMiles.toLocaleString()} + miles + +) : metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + — +) : ( + Unavailable +)}
- -All-time telemetry aggregate +{metricsStatus === 'ready' ? ( + <> + + All-time telemetry aggregate + +) : metricsStatus === 'loading' ? ( + Retrieving telemetry aggregate… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to view telemetry aggregate +) : metricsStatus === 'permission-denied' ? ( + Access to cloud telemetry restricted +) : ( + Telemetry aggregate unavailable +)}
{/* Metric 2: Sessions */} -
+
Recorded Sessions -100% Cloud Synced +{metricsStatus === 'loading' ? ( + Checking sync… +) : metricsStatus === 'unauthenticated' ? ( + Sign-in required +) : metricsStatus === 'permission-denied' ? ( + Permission denied +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Sync status unavailable +) : metrics !== null && metrics.rideCount > 0 ? ( + 100% Cloud Synced +) : ( + Cloud Synced +)}
-{metrics.rideCount} -rides +{metricsStatus === 'ready' && metrics !== null ? ( + <> + {metrics.rideCount} + rides + +) : metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + — +) : ( + Unavailable +)}
-Canyons, Circuits & Commutes +{metricsStatus === 'ready' && metrics !== null ? ( + metrics.rideCount > 0 ? ( + Canyons, Circuits & Commutes + ) : ( + No rides recorded yet + ) +) : metricsStatus === 'loading' ? ( + Checking cloud telemetry sessions… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to load synced rides +) : metricsStatus === 'permission-denied' ? ( + Check Firestore permissions +) : ( + Could not retrieve sessions from cloud +)}
{/* Metric 3: Lean Angle Peak */} -
+
Peak Lean Angle -Lateral Roll +{metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to view +) : metricsStatus === 'permission-denied' ? ( + Permission denied +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Sync unavailable +) : ( + Lateral Roll +)}
-{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} -lateral roll +{metricsStatus === 'ready' && metrics !== null ? ( + <> + + {metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} + + lateral roll + +) : metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + — +) : ( + Unavailable +)}
-6-Axis IMU Verified +{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? ( + 6-Axis IMU Verified +) : metricsStatus === 'loading' ? ( + Retrieving lean telemetry… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to view lean telemetry +) : metricsStatus === 'permission-denied' ? ( + Lean telemetry restricted +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Lean telemetry unavailable +) : ( + No IMU roll data +)}
{/* Metric 4: Hardware Link */} @@ -310,11 +448,26 @@ export default function AccountPage() { syncTrigger={syncTrigger} onSyncStateChange={(syncing) => { setIsSyncing(syncing); - if (!syncing) { + }} + onStatusChange={(status, _err) => { + setMetricsStatus(status); + if (status === 'ready') { + setSyncError(null); setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + } else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') { + setMetrics(null); + setSyncError('Sync failed'); + } else if (status === 'unauthenticated') { + setMetrics(null); + setSyncError(null); + } else if (status === 'loading') { + setMetrics(null); } }} - onMetricsLoaded={(m) => setMetrics(m)} + onMetricsLoaded={(m) => { + setMetrics(m); + setMetricsStatus('ready'); + }} /> {/* BEGIN: PlanAndClusterSection */}
@@ -438,14 +591,14 @@ export default function AccountPage() {
App Connections & Sync - - -All Synced + + +{user ? 'Account linked' : 'Not signed in'}
{/* Sync List */}
-{/* Google App Account */} +{/* Google App Account (real: reflects signed-in Firebase user) */}
@@ -460,37 +613,41 @@ export default function AccountPage() { {user ? 'Connected' : 'Not Connected'}
-{/* MotorSignal Mobile App */} +{/* Authenticated devices (real: from /api/account/sessions) */}
-iOS Mobile App -iPhone 16 Pro • v4.1.0 +Authenticated Devices + + {!user ? 'Sign in to view linked devices' : isLoadingSessions ? 'Checking sessions…' : 'Manage in Active Device Sessions below'} +
-14m ago + 0 ? 'text-brand-emerald bg-emerald-500/10 border-emerald-500/20' : 'text-slate-400 bg-slate-800 border-slate-700'}`}> + {!user ? '—' : isLoadingSessions ? '…' : `${sessions.length} active`} +
-{/* Comms / Cardo */} +{/* Helmet comms / accessories — Bluetooth pairing is not observable from a browser */}
-Helmet Comms Link -Cardo Packtalk Edge +Helmet Comms & Accessories +Bluetooth pairing is managed in the mobile app
-Paired +Unavailable on web
Apple Watch Companion: Not Connected (Unavailable on Web) - +Manage Devices
@@ -498,7 +655,7 @@ export default function AccountPage() { {/* BEGIN: SecurityAndSessionsGrid */}
{/* Sessions (7 cols) */} -
+

Active Device Sessions

@@ -600,27 +757,31 @@ export default function AccountPage() {
-{/* 2FA State */} +{/* 2FA State — reflects real Firebase multi-factor enrollment; enrollment is managed in the mobile app */}
Two-Factor Authentication (2FA) -Hardware TOTP Authenticator +{twoFactorEnrolled ? 'Authenticator enrolled' : 'Enroll in the mobile app'}
+{twoFactorEnrolled ? ( ENABLED +) : ( +NOT ENABLED +)}
-{/* Leaderboard Privacy */} +{/* Leaderboard Privacy — managed in the mobile app; the web app has no privacy toggle backend */}
Telemetry Privacy Mode -Public Leaderboard (Club Trophies) +Leaderboard visibility is managed in the mobile app
- +In mobile app
Telemetry Data Retention: 100% Retained -Security Logs +Review active sessions
diff --git a/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx b/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx index 6c74f0d..11fa882 100644 --- a/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx +++ b/website/app/clubs/[clubId]/manage/ranks-roles/page.tsx @@ -34,7 +34,6 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) { const [members, setMembers] = useState([]); const [permissions, setPermissions] = useState(DEFAULT_RANK_PERMISSIONS); const [selectedRankId, setSelectedRankId] = useState('president'); - const [isSavingPermissions, setIsSavingPermissions] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [unauthenticated, setUnauthenticated] = useState(false); @@ -104,16 +103,6 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) { })); } - function handleResetDefaults() { - setPermissions(DEFAULT_RANK_PERMISSIONS); - } - - async function handleSavePermissions() { - setIsSavingPermissions(true); - await new Promise((r) => setTimeout(r, 400)); - setIsSavingPermissions(false); - } - if (loading) { return (
@@ -220,16 +209,23 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) { /> {/* Interactive Permission Toggle Switches Matrix */} + {/* + Per-rank capability permissions have no server-side persistence contract yet. + The enforced authorization model is role-based (Owner / Admin) via + getPortalPermissions in lib/club-data.ts, not this rank matrix. Rendering the + matrix read-only (persistenceAvailable={false}) keeps it truthful: it shows the + standard governance conventions without claiming a save that does not occur. + See website/docs/backend-contracts/club-rank-permissions.md for the proposed + contract that would flip persistenceAvailable to true. + */}
diff --git a/website/app/support/page.tsx b/website/app/support/page.tsx index 4e09c33..7539bc3 100644 --- a/website/app/support/page.tsx +++ b/website/app/support/page.tsx @@ -1 +1,72 @@ -export default function SupportPage() { return
Support

Help for the road ahead.

Account and product support will be available here as the MotorSignal web platform comes online.

Contact support
; } +import { SupportContactForm } from '../../components/support-contact-form'; + +const SUPPORT_EMAIL = 'support@motorsignal.app'; + +// Grounded in features that actually ship today. Keep answers truthful: anything managed +// only in the mobile app, or not yet available on the web, is stated plainly. +const FAQ: { q: string; a: string }[] = [ + { + q: 'Can I use MotorSignal without an internet connection?', + a: 'Yes. Ride recording and telemetry run on-device, so you can record offline and while signed out. Your rides sync to the cloud once you are signed in and back online — nothing is lost in the meantime.', + }, + { + q: 'How do I manage my subscription or find my invoices?', + a: 'Subscriptions, payment methods, and invoices are managed in the MotorSignal mobile app. The web billing portal is not available yet, so the Account page will point you to the app for billing changes.', + }, + { + q: 'How do I turn on two-factor authentication (2FA)?', + a: 'Two-factor authentication is enrolled in the mobile app. The web Account page reflects your real 2FA status — it will show “Enabled” only once a factor is actually enrolled.', + }, + { + q: 'Can I export my ride data?', + a: 'Yes. On the web Account page, each ride can be exported as a GPX track or a CSV of its telemetry. Your historical exports are not altered after the fact.', + }, + { + q: 'How do I sign out other devices or browsers?', + a: 'Open Account → Active Device Sessions. You can revoke an individual session, or revoke all sessions at once. The session you are currently using is marked “this device”.', + }, + { + q: 'Why don’t I see live riders on the web radar?', + a: 'The Formation Radar shows live rider positions only for a group ride you are an accepted participant in. Outside of that it displays a clearly labelled demonstration, not real riders.', + }, + { + q: 'What’s the difference between the public Group Rides page and my hub?', + a: 'Signed out, the Group Rides page is a public showcase with sample formations. Signed in, it becomes your hub where you create, manage, join, and leave real rides.', + }, +]; + +export default function SupportPage() { + return ( +
+ Support +

Help for the road ahead.

+

+ Search the answers below for the quickest fix. If you still need a hand, send us an email and + we’ll get back to you. +

+ +
+

Contact support

+

+ Reach the team at{' '} + {SUPPORT_EMAIL}. Prefer a form? Fill this in and it will + open a prefilled email for you to send. +

+
+ +
+
+ +
+ Common questions +

Frequently asked

+ {FAQ.map((item) => ( +
+ {item.q} +

{item.a}

+
+ ))} +
+
+ ); +} diff --git a/website/components/app-cloud-data.tsx b/website/components/app-cloud-data.tsx index cbb32fe..5151ae7 100644 --- a/website/components/app-cloud-data.tsx +++ b/website/components/app-cloud-data.tsx @@ -139,15 +139,40 @@ function StockMotorcycleGraphic({ className = 'w-full h-40' }: { className?: str ); } -interface AppCloudDataProps { - syncTrigger?: number; - onSyncStateChange?: (syncing: boolean) => void; - onMetricsLoaded?: (metrics: { totalMiles: number; rideCount: number; peakLean: number }) => void; +export interface AccountCloudMetrics { + totalMiles: number; + rideCount: number; + peakLean: number; } -export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoaded }: AppCloudDataProps) { +export type CloudDataStatus = + | 'idle' + | 'loading' + | 'unauthenticated' + | 'ready' + | 'permission-denied' + | 'unavailable' + | 'error'; + +export interface AppCloudDataProps { + syncTrigger?: number; + onSyncStateChange?: (syncing: boolean) => void; + onMetricsLoaded?: (metrics: AccountCloudMetrics) => void; + onStatusChange?: (status: CloudDataStatus, error?: unknown) => void; +} + +export function AppCloudData({ + syncTrigger = 0, + onSyncStateChange, + onMetricsLoaded, + onStatusChange, +}: AppCloudDataProps) { const [motorcycles, setMotorcycles] = useState([]); const [rides, setRides] = useState([]); + const [garageStatus, setGarageStatus] = useState('loading'); + const [ridesStatus, setRidesStatus] = useState('loading'); + const [garageError, setGarageError] = useState(null); + const [ridesError, setRidesError] = useState(null); const [state, setState] = useState('Loading telemetry cloud data...'); const [user, setUser] = useState(null); @@ -331,109 +356,185 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad if (!currentUser) { setMotorcycles([]); setRides([]); + setGarageStatus('unauthenticated'); + setRidesStatus('unauthenticated'); + setGarageError(null); + setRidesError(null); setState('Sign in or connect your Google account to view your live telemetry.'); - if (onMetricsLoaded) onMetricsLoaded({ totalMiles: 0, rideCount: 0, peakLean: 0 }); + if (onStatusChange) onStatusChange('unauthenticated'); return; } + setGarageStatus('loading'); + setRidesStatus('loading'); + setGarageError(null); + setRidesError(null); + if (onStatusChange) onStatusChange('loading'); if (onSyncStateChange) onSyncStateChange(true); + + function classifyError(err: unknown): { status: CloudDataStatus; message: string } { + const msg = err instanceof Error ? err.message : String(err); + const code = (err as { code?: string })?.code; + if ( + code === 'permission-denied' || + msg.includes('permission-denied') || + msg.includes('Missing or insufficient permissions') + ) { + return { status: 'permission-denied', message: 'Permission denied: unable to access cloud telemetry.' }; + } + if ( + code === 'unavailable' || + msg.includes('unavailable') || + msg.includes('offline') || + msg.includes('network') || + msg.includes('Failed to fetch') + ) { + return { status: 'unavailable', message: 'Network offline: unable to reach MotorSignal cloud.' }; + } + return { status: 'error', message: msg || 'An unexpected error occurred while loading cloud telemetry.' }; + } + try { - const [motorcycleSnapshot, rideSnapshot] = await Promise.all([ + const [motorcycleRes, rideRes] = await Promise.allSettled([ getDocs(collection(firestore, 'users', currentUser.uid, 'motorcycles')), getDocs(collection(firestore, 'users', currentUser.uid, 'rides')), ]); - const loadedMotorcycles: Motorcycle[] = motorcycleSnapshot.docs.map((snapshot) => { - const data = snapshot.data(); - const yearVal = number(data.year) || 0; - const makeVal = text(data.makeName) || text(data.make); - const modelVal = text(data.modelName) || text(data.model); - const variantVal = text(data.variantName) || text(data.variant); - const description = [yearVal || '', makeVal, modelVal, variantVal].filter(Boolean).join(' '); - return { - id: snapshot.id, - nickname: text(data.nickname) || description || 'Motorcycle', - makeName: makeVal, - modelName: modelVal, - variantName: variantVal, - year: yearVal, - description, - isDefault: data.isDefault === true, - imageUrl: text(data.imageUrl) || undefined, - vin: text(data.vin) || text(data.vinNumber) || undefined, - }; - }).sort((a, b) => Number(b.isDefault) - Number(a.isDefault)); + // Guard against race conditions if user logged out or changed while async queries were in-flight + if (firebaseAuth.currentUser?.uid !== currentUser.uid) { + return; + } - let totalMiles = 0; - let peakLean = 0; + // Process Motorcycles + if (motorcycleRes.status === 'fulfilled') { + const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => { + const data = snapshot.data(); + const yearVal = number(data.year) || 0; + const makeVal = text(data.makeName) || text(data.make); + const modelVal = text(data.modelName) || text(data.model); + const variantVal = text(data.variantName) || text(data.variant); + const description = [yearVal || '', makeVal, modelVal, variantVal].filter(Boolean).join(' '); + return { + id: snapshot.id, + nickname: text(data.nickname) || description || 'Motorcycle', + makeName: makeVal, + modelName: modelVal, + variantName: variantVal, + year: yearVal, + description, + isDefault: data.isDefault === true, + imageUrl: text(data.imageUrl) || undefined, + vin: text(data.vin) || text(data.vinNumber) || undefined, + }; + }).sort((a, b) => Number(b.isDefault) - Number(a.isDefault)); - const loadedRides: Ride[] = rideSnapshot.docs.map((snapshot) => { - const data = snapshot.data(); - const rawStart = text(data.startTime); - const rawEnd = text(data.endTime); - const startedAt = rawStart ? new Date(rawStart) : null; - const endedAt = rawEnd ? new Date(rawEnd) : null; - const dist = number(data.distanceMiles); - const leanL = number(data.maxLeanLeft); - const leanR = number(data.maxLeanRight); + setMotorcycles(loadedMotorcycles); + setGarageStatus('ready'); + setGarageError(null); + } else { + const classified = classifyError(motorcycleRes.reason); + setGarageStatus(classified.status); + setGarageError(classified.message); + console.error('Error loading motorcycles:', motorcycleRes.reason); + } - totalMiles += dist; - if (leanL > peakLean) peakLean = leanL; - if (leanR > peakLean) peakLean = leanR; + // Process Rides & Metrics + if (rideRes.status === 'fulfilled') { + let totalMiles = 0; + let peakLean = 0; - const startLat = number(data.startLat) || number(data.startLatitude) || undefined; - const startLon = number(data.startLon) || number(data.startLongitude) || undefined; - const endLat = number(data.endLat) || number(data.endLatitude) || undefined; - const endLon = number(data.endLon) || number(data.endLongitude) || undefined; - const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined; + const loadedRides: Ride[] = rideRes.value.docs.map((snapshot) => { + const data = snapshot.data(); + const rawStart = text(data.startTime); + const rawEnd = text(data.endTime); + const startedAt = rawStart ? new Date(rawStart) : null; + const endedAt = rawEnd ? new Date(rawEnd) : null; + const dist = number(data.distanceMiles); + const leanL = number(data.maxLeanLeft); + const leanR = number(data.maxLeanRight); - let ridePoints: TelemetryPoint[] | undefined; - if (Array.isArray(data.points) && data.points.length > 0) { - ridePoints = data.points; - } else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) { - ridePoints = data.telemetryPoints; - } + totalMiles += dist; + if (leanL > peakLean) peakLean = leanL; + if (leanR > peakLean) peakLean = leanR; - return { - id: snapshot.id, - startedAt: startedAt && !Number.isNaN(startedAt.getTime()) ? startedAt : null, - endedAt: endedAt && !Number.isNaN(endedAt.getTime()) ? endedAt : null, - distanceMiles: dist, - topSpeedMph: number(data.topSpeedMph), - averageSpeed: number(data.averageSpeed), - maxLeanLeft: leanL, - maxLeanRight: leanR, - maxAccelG: number(data.maxAccelG), - maxBrakingG: number(data.maxBrakingG), - movingTime: number(data.movingTime), - elevationGain: number(data.elevationGain), - qualityScore: number(data.qualityScore) || 100, - motorcycle: [number(data.motorcycleYear) || '', text(data.motorcycleMake), text(data.motorcycleModel)].filter(Boolean).join(' '), - startLat, - startLon, - endLat, - endLon, - points: ridePoints, - gpxContent: text(data.gpxContent) || undefined, - csvContent: text(data.csvContent) || undefined, - telemetryBundlePath: bundlePath, - }; - }).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0)); + const startLat = number(data.startLat) || number(data.startLatitude) || undefined; + const startLon = number(data.startLon) || number(data.startLongitude) || undefined; + const endLat = number(data.endLat) || number(data.endLatitude) || undefined; + const endLon = number(data.endLon) || number(data.endLongitude) || undefined; + const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined; - setMotorcycles(loadedMotorcycles); - setRides(loadedRides); - setState(''); + let ridePoints: TelemetryPoint[] | undefined; + if (Array.isArray(data.points) && data.points.length > 0) { + ridePoints = data.points; + } else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) { + ridePoints = data.telemetryPoints; + } - if (onMetricsLoaded) { - onMetricsLoaded({ + return { + id: snapshot.id, + startedAt: startedAt && !Number.isNaN(startedAt.getTime()) ? startedAt : null, + endedAt: endedAt && !Number.isNaN(endedAt.getTime()) ? endedAt : null, + distanceMiles: dist, + topSpeedMph: number(data.topSpeedMph), + averageSpeed: number(data.averageSpeed), + maxLeanLeft: leanL, + maxLeanRight: leanR, + maxAccelG: number(data.maxAccelG), + maxBrakingG: number(data.maxBrakingG), + movingTime: number(data.movingTime), + elevationGain: number(data.elevationGain), + qualityScore: number(data.qualityScore) || 100, + motorcycle: [number(data.motorcycleYear) || '', text(data.motorcycleMake), text(data.motorcycleModel)].filter(Boolean).join(' '), + startLat, + startLon, + endLat, + endLon, + points: ridePoints, + gpxContent: text(data.gpxContent) || undefined, + csvContent: text(data.csvContent) || undefined, + telemetryBundlePath: bundlePath, + }; + }).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0)); + + setRides(loadedRides); + setRidesStatus('ready'); + setRidesError(null); + + const computedMetrics = { totalMiles: Math.round(totalMiles), rideCount: loadedRides.length, peakLean: Number(peakLean.toFixed(1)), - }); + }; + + if (onMetricsLoaded) { + onMetricsLoaded(computedMetrics); + } + if (onStatusChange) { + onStatusChange('ready'); + } + } else { + const classified = classifyError(rideRes.reason); + setRidesStatus(classified.status); + setRidesError(classified.message); + console.error('Error loading telemetry rides:', rideRes.reason); + if (onStatusChange) { + onStatusChange(classified.status, rideRes.reason); + } + } + + if (motorcycleRes.status === 'rejected' && rideRes.status === 'rejected') { + setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.'); + } else if (motorcycleRes.status === 'rejected' || rideRes.status === 'rejected') { + setState('Some cloud telemetry could not be loaded.'); + } else { + setState(''); } } catch (error) { console.error('Error loading MotorSignal cloud data:', error); setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.'); + if (onStatusChange) { + onStatusChange('error', error); + } } finally { if (onSyncStateChange) onSyncStateChange(false); } @@ -855,6 +956,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
))} + ) : garageStatus === 'loading' ? ( +
+
+

Loading motorcycle garage…

+
+ ) : garageStatus === 'permission-denied' || garageStatus === 'unavailable' || garageStatus === 'error' ? ( +
+
+ ! +
+

+ {garageStatus === 'permission-denied' + ? 'Garage Telemetry Restricted — Permission Denied' + : 'Garage Telemetry Unavailable'} +

+

+ {garageError || (garageStatus === 'permission-denied' + ? 'Your account does not have permission to read motorcycles from Firestore.' + : 'Unable to load motorcycle garage from cloud. Please verify connection and retry.')} +

+ +
) : (
@@ -890,9 +1019,19 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad

- - {rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded - + {ridesStatus === 'loading' ? ( + + Checking sessions… + + ) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? ( + + Feed Unavailable + + ) : ( + + {rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded + + )} {rides.length > 0 ? ( @@ -1098,6 +1237,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad )} + ) : ridesStatus === 'loading' ? ( +
+
+

Loading telemetry sessions…

+
+ ) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? ( +
+
+ ! +
+

+ {ridesStatus === 'permission-denied' + ? 'Recent Rides Restricted — Permission Denied' + : 'Recent Rides Cloud Feed Unavailable'} +

+

+ {ridesError || (ridesStatus === 'permission-denied' + ? 'Firestore rejected read access to telemetry sessions for this rider.' + : 'Unable to retrieve telemetry sessions from MotorSignal cloud. Please check network and retry.')} +

+ +
) : (
diff --git a/website/components/founder-portal/club-permission-matrix.tsx b/website/components/founder-portal/club-permission-matrix.tsx index ebb98d5..86dcfc9 100644 --- a/website/components/founder-portal/club-permission-matrix.tsx +++ b/website/components/founder-portal/club-permission-matrix.tsx @@ -9,10 +9,22 @@ interface ClubPermissionMatrixProps { permissions: Record>; selectedRankId: string | null; onTogglePermission: (rankId: string, capability: ClubCapability) => void; - onResetDefaults: () => void; - onSavePermissions: () => Promise; - isSaving: boolean; + onResetDefaults?: () => void; + onSavePermissions?: () => Promise; + isSaving?: boolean; canEdit: boolean; + /** + * Whether a server-side persistence contract for per-rank capability + * permissions actually exists. Defaults to false. + * + * The enforced club authorization model is role-based (Owner / Admin), not + * rank-based (see getPortalPermissions in lib/club-data.ts). No Firestore + * document or API persists this capability matrix today, so by default the + * matrix renders as a read-only reference of the standard governance model. + * The editable + save path is retained for when a backend contract ships + * (see website/docs/backend-contracts/club-rank-permissions.md). + */ + persistenceAvailable?: boolean; } export function ClubPermissionMatrix({ @@ -23,12 +35,19 @@ export function ClubPermissionMatrix({ onTogglePermission, onResetDefaults, onSavePermissions, - isSaving, + isSaving = false, canEdit, + persistenceAvailable = false, }: ClubPermissionMatrixProps) { const [saveSuccess, setSaveSuccess] = useState(false); + // Edit + save controls are only live when the caller both grants edit rights + // AND a real persistence backend is wired. Without persistence the matrix is + // a truthful read-only reference: it never mutates and never reports a save. + const editable = canEdit && persistenceAvailable && typeof onSavePermissions === 'function'; + async function handleSave() { + if (!editable || !onSavePermissions) return; await onSavePermissions(); setSaveSuccess(true); setTimeout(() => setSaveSuccess(false), 2500); @@ -48,19 +67,21 @@ export function ClubPermissionMatrix({

- Configure delegated operational privileges per squad rank. Administrative roles enforce backend limits. + Standard delegated operational privileges per squad rank. Administrative roles enforce backend limits.

- {canEdit && ( + {editable ? (
- + {onResetDefaults && ( + + )}
+ ) : ( + + visibility + View-only reference + )} + {/* Truthful availability notice: shown whenever persistence is not wired. */} + {!editable && ( +
+ + info + + + This matrix shows MotorSignal’s standard rank conventions for reference. Custom per-rank + permissions are not saved yet. Who can actually manage this club is enforced by the{' '} + Owner and{' '} + Admin roles in the member roster — see + Ranks & Roles above. Per-rank customization is planned. + +
+ )} + {/* Desktop / Tablet Matrix Table */}
@@ -142,17 +187,22 @@ export function ClubPermissionMatrix({ {capabilities.map((cap) => { const isGranted = rankPerms[cap.id] ?? false; const isImmutableOwner = rank.id === 'president'; + const toggleDisabled = !editable || isImmutableOwner; return (
diff --git a/website/components/group-rides/group-rides-management.tsx b/website/components/group-rides/group-rides-management.tsx index 9d7a2f8..3a0ec1b 100644 --- a/website/components/group-rides/group-rides-management.tsx +++ b/website/components/group-rides/group-rides-management.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import Link from 'next/link'; import { getApps, initializeApp } from 'firebase/app'; import { getAuth, connectAuthEmulator } from 'firebase/auth'; @@ -23,6 +23,7 @@ import { connectFirestoreEmulator, } from 'firebase/firestore'; import { firestore } from '../../lib/firebase-client'; +import { commitGroupRide } from '../../lib/create-group-ride'; function getActiveFirestore() { if ( @@ -182,7 +183,7 @@ type MyRidesFilter = 'all' | 'organized' | 'joined'; interface CreateRideModalProps { isOpen: boolean; onClose: () => void; - onCreateRide: (ride: GroupRideSummary) => void; + onCreateRide: (ride: GroupRideSummary) => void | boolean | Promise; } function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) { @@ -221,6 +222,7 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal const [participantLimit, setParticipantLimit] = useState(12); const [description, setDescription] = useState(''); const [errorMsg, setErrorMsg] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); // Reset form when modal opens useEffect(() => { @@ -244,6 +246,7 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal setParticipantLimit(12); setDescription(''); setErrorMsg(null); + setIsSubmitting(false); } }, [isOpen, defaultDateValues]); @@ -287,8 +290,9 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal } }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (isSubmitting) return; if (!validate()) return; const newRide: GroupRideSummary & { @@ -320,8 +324,15 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal ...(rideType === 'club' && clubName.trim() ? { clubName: clubName.trim(), clubId: `club-${clubName.toLowerCase().replace(/\s+/g, '-')}` } : {}), }; - onCreateRide(newRide); - onClose(); + setIsSubmitting(true); + try { + const outcome = await onCreateRide(newRide); + if (outcome !== false) { + onClose(); + } + } finally { + setIsSubmitting(false); + } }; return ( @@ -707,9 +718,12 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal @@ -724,6 +738,8 @@ export function GroupRidesManagement({ onViewShowcase, }: GroupRidesManagementProps) { const [rides, setRides] = useState(INITIAL_GROUP_RIDES); + // Guards against duplicate create submissions while a write is in flight. + const creatingRideRef = useRef(false); const [communityRides, setCommunityRides] = useState([]); const [isCommunityLoading, setIsCommunityLoading] = useState(false); const [communityError, setCommunityError] = useState(null); @@ -1000,98 +1016,59 @@ export function GroupRidesManagement({ }, [currentUser?.uid]); // Handlers for Add, Alter, Remove, Join, Leave - const handleCreateRide = async (newRide: GroupRideSummary & { visibility?: string; clubId?: string }) => { + const handleCreateRide = async (newRide: GroupRideSummary & { visibility?: string; clubId?: string; clubName?: string }): Promise => { + // Prevent duplicate submissions while a create is already in flight. + if (creatingRideRef.current) return false; + 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)}`; + // Check authentication and persistence availability BEFORE touching UI state, so an + // unauthenticated user (or an unavailable Firestore) never sees a phantom ride that was + // never saved. They get an actionable error instead. + if (!uid || !firestore) { + showAlert('Sign in to create a group ride — it could not be saved.', 'error'); + return false; + } - const rideWithOrganizer: GroupRideSummary = { - ...newRide, - id: rideId, - isOrganizer: true, - status: 'scheduled', - userStatus: 'joined', - rideCode: rideId, - leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle', - leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(), - }; + creatingRideRef.current = true; + try { + const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer'; + const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider'; - // Optimistic local update - setRides((prev) => [rideWithOrganizer, ...prev]); - showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success'); + const result = await commitGroupRide(newRide, { + uid, + firestore, + leadDisplayName, + leadHandle, + writeBatch: (fs) => writeBatch(fs), + docRef: (fs, ...segments) => doc(fs, segments[0], ...segments.slice(1)), + }); - 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() + 3600000).toISOString(); - let departureAt = new Date(now.getTime() + 3600000 + 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: any = { - id: rideId, - organizerUid: uid, - organizerHandle: leadHandle, - organizerDisplayName: leadDisplayName, - title: newRide.title.trim(), - description: newRide.description?.trim() || '', - visibility: newRide.visibility || 'public', - joinPolicy: newRide.joinPolicy || 'requestToJoin', + if (result.ok) { + // Insert into UI state ONLY after Firestore confirms the write — no phantom rides. + const rideWithOrganizer: GroupRideSummary = { + ...newRide, + id: result.rideId, + isOrganizer: true, 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 || '', + userStatus: 'joined', + rideCode: result.rideId, + leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle', + leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(), }; - - if (newRide.clubId) { - firestoreRideData.clubId = newRide.clubId; - if (newRide.clubName) firestoreRideData.clubName = newRide.clubName; - } - - 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); + setRides((prev) => (prev.some((r) => r.id === result.rideId) ? prev : [rideWithOrganizer, ...prev])); + showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${result.rideId}`, 'success'); + return true; + } else if (result.reason === 'commit-failed') { + console.error('Failed to persist group ride to Firestore:', result.error); + showAlert('Could not create the ride. Please check your connection and try again.', 'error'); + return false; + } else { + // unauthenticated / unavailable discovered inside the helper (defensive — guarded above). + showAlert('Could not create the ride right now. Please try again.', 'error'); + return false; } + } finally { + creatingRideRef.current = false; } }; diff --git a/website/components/support-contact-form.tsx b/website/components/support-contact-form.tsx new file mode 100644 index 0000000..9bc6c9f --- /dev/null +++ b/website/components/support-contact-form.tsx @@ -0,0 +1,153 @@ +'use client'; + +import React, { useState } from 'react'; + +const SUPPORT_EMAIL = 'support@motorsignal.app'; + +const CATEGORIES = [ + 'Account & sign-in', + 'Subscription & billing', + 'Group rides', + 'Clubs', + 'Telemetry & ride data', + 'Bug report', + 'Something else', +] as const; + +type Errors = { name?: string; email?: string; message?: string }; + +// Public support contact. There is no public ticket backend, so this never claims a +// ticket was filed: it validates input and opens the visitor's own email client with a +// prefilled message. Email remains the real, working support channel. +export function SupportContactForm() { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [category, setCategory] = useState<(typeof CATEGORIES)[number]>(CATEGORIES[0]); + const [message, setMessage] = useState(''); + const [errors, setErrors] = useState({}); + const [opened, setOpened] = useState(false); + + function validate(): Errors { + const next: Errors = {}; + if (!name.trim()) next.name = 'Please tell us your name.'; + // Pragmatic email check: a single @ with a dot in the domain. + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + next.email = 'Enter a valid email address so we can reply.'; + } + if (message.trim().length < 10) { + next.message = 'Add a few more details (at least 10 characters).'; + } + return next; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const found = validate(); + setErrors(found); + if (Object.keys(found).length > 0) { + setOpened(false); + return; + } + + const subject = `[${category}] Support request from ${name.trim()}`; + const body = + `Name: ${name.trim()}\n` + + `Email: ${email.trim()}\n` + + `Category: ${category}\n\n` + + `${message.trim()}\n`; + const mailto = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + + // Open the visitor's email client. We do not and cannot confirm delivery here. + if (typeof window !== 'undefined') { + window.location.href = mailto; + } + setOpened(true); + } + + const inputClass = + 'w-full px-3.5 py-2.5 rounded-xl bg-surface-container-low border border-outline-variant/40 focus:border-primary-container focus:ring-1 focus:ring-primary-container text-white text-sm outline-none transition placeholder:text-slate-500'; + + return ( +
+
+
+ + setName(e.target.value)} + className={inputClass} + placeholder="Alex Rider" + aria-invalid={Boolean(errors.name)} + /> + {errors.name &&

{errors.name}

} +
+ +
+ + setEmail(e.target.value)} + className={inputClass} + placeholder="you@example.com" + aria-invalid={Boolean(errors.email)} + /> + {errors.email &&

{errors.email}

} +
+
+ +
+ + +
+ +
+ +