diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index 2094e66..63b4c3d 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -49,7 +49,7 @@ export default function AccountPage() {
{initials}
- + @@ -176,10 +176,10 @@ export default function AccountPage() {
-OBD-II CAN v3.2 +Not connected
-Active Sync @ 10Hz Multi-GNSS +OBD-II / CAN telemetry unavailable
@@ -203,13 +203,13 @@ export default function AccountPage() {
-Active Subscription Plan +Subscription billing

-Pro Telemetry Tier -Active • Renews Sept 2027 +Not connected +Web billing unavailable

-$189/yr billed +No active plan data
{/* Inclusions Pills */}
@@ -217,33 +217,31 @@ export default function AccountPage() {
-10Hz Multi-GNSS Logging +Phone-based ride recording -ECU CAN Bus Full Trace +OBD-II / ECU telemetry unavailable -Unlimited Cloud Storage +Cloud sync status shown below -Crash SOS Beacon (<150ms) +Emergency dispatch is not provided
{/* Linked Hardware Details */}
-Registered Dongle -MotorSignal CAN-Link OBD-II -
SN: MS-LINK-9021-X
+Motorcycle hardware +Not available
-Firmware Status -v2.4.1 (Current) -
Last Check: Today, 09:12 AM
+Device status +No device linked
@@ -263,10 +261,10 @@ export default function AccountPage() { Founder Portal @@ -346,8 +344,8 @@ export default function AccountPage() {
-

Security & Rider SOS

-

Emergency contact routing and app account link

+

Account & Security

+

Account-link status and security settings

{/* Google App Link */} diff --git a/website/app/clubs/[clubId]/manage/chat/page.tsx b/website/app/clubs/[clubId]/manage/chat/page.tsx new file mode 100644 index 0000000..65269cf --- /dev/null +++ b/website/app/clubs/[clubId]/manage/chat/page.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { use } from 'react'; +import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page'; + +export default function ChatPage({ params }: { params: Promise<{ clubId: string }> }) { + const { clubId } = use(params); + return ; +} diff --git a/website/app/clubs/[clubId]/manage/routes/page.tsx b/website/app/clubs/[clubId]/manage/routes/page.tsx new file mode 100644 index 0000000..f00e8b9 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/routes/page.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { use } from 'react'; +import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page'; + +export default function RoutesPage({ params }: { params: Promise<{ clubId: string }> }) { + const { clubId } = use(params); + return ; +} diff --git a/website/app/clubs/page.tsx b/website/app/clubs/page.tsx index f243254..426168e 100644 --- a/website/app/clubs/page.tsx +++ b/website/app/clubs/page.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; export default function ClubsPage() { return (
-
+
+
+
+

Club tools, with a clear feature status

+

The Founder Portal supports member and Wall management, Stats, and moderation tools for membership and Wall posts. Routes, chat, post reports, CAN-bus diagnostics, crash dispatch, and off-grid mesh are not available. Directory and telemetry examples on this page were sample content, not live club data.

+ Open Founder Portal +
+
); diff --git a/website/app/features/page.tsx b/website/app/features/page.tsx index 7a25db3..4ec4536 100644 --- a/website/app/features/page.tsx +++ b/website/app/features/page.tsx @@ -7,11 +7,8 @@ export default function FeaturesPage() { useEffect(() => { let speed = 174; let lean = 52.8; - let countdown = 8; - const speedEl = document.getElementById('speed-val'); const leanEl = document.getElementById('lean-val'); - const countdownEl = document.getElementById('sos-countdown'); const i1 = setInterval(() => { if (speedEl) { @@ -26,17 +23,8 @@ export default function FeaturesPage() { } }, 800); - const i2 = setInterval(() => { - if (countdownEl) { - countdown--; - if (countdown < 0) countdown = 8; - countdownEl.textContent = '00:0' + countdown + 's'; - } - }, 1000); - return () => { clearInterval(i1); - clearInterval(i2); }; }, []); @@ -64,7 +52,7 @@ export default function FeaturesPage() { Surgical data precision for every apex, straightaway, and canyon.

- Explore the full spectrum of MotorSignal hardware integration, real-time cockpit intelligence, crash telemetry, and race-grade analysis calibrated for professional riders and trackside telemetry engineers. + Explore phone-based ride recording, performance tools, and rider community features available in MotorSignal today.

@@ -92,10 +80,10 @@ export default function FeaturesPage() { 02 Lap Analysis & Sectors - 03 Crash & SOS Beacon + 03 Ride Records & Road Alerts - 04 CAN-bus & ECU Stream + 04 Phone Sensor Telemetry 05 Cloud Sync & Replay @@ -432,10 +420,10 @@ export default function FeaturesPage() {

- Emergency Impact Detection & SOS Beacon + Ride Records & Rider-Reported Hazards

- Riding at the limit demands uncompromising safety automation. MotorSignal continuously parses 6-axis G-force spikes and gyro inversions to trigger life-saving protocols in milliseconds. + Record ride telemetry on your phone and share road hazards with other riders. MotorSignal does not automatically detect crashes or contact emergency services.

{/* Feature Bullets */}
@@ -444,9 +432,9 @@ export default function FeaturesPage() { sensors
-

<150ms High-G Impact Trigger

+

Local Ride Recording

- Tri-axial MEMS sensors distinguish between road vibration, pothole shocks, and actual bike separation or highside events. + Ride recording uses your phone sensors and remains available when cloud services are offline.

@@ -455,9 +443,9 @@ export default function FeaturesPage() { satellite_alt
-

Dual Cellular & Satellite Mesh Dispatch

+

Rider-Submitted Road Alerts

- Transmits GPS coordinates, rider blood type, impact force severity, and local EMS telemetry package via cellular carrier failover. + Report a road hazard or incident from the app. Alerts are not emergency dispatch and require network access to share.

@@ -466,9 +454,9 @@ export default function FeaturesPage() { lock_reset
-

Cryptographic Cloud Blackbox Preservation

+

Your Ride Data

- The trailing 30 seconds of pre-impact CAN-bus throttle, braking, lean, and camera triggers are locked and preserved to encrypted cloud storage. + Review recorded ride history and export supported ride data. No CAN-bus or camera data is captured by this feature.

@@ -477,11 +465,11 @@ export default function FeaturesPage() {
verified -FIM Telemetry Spec 2025 +Phone Sensor Telemetry
shield -ISO 26262 ASIL-B Safety +Offline-Ready Recording
@@ -490,28 +478,28 @@ export default function FeaturesPage() {
-Tactical Crash Simulator Demo +Illustrative UI Preview — Not an Emergency Service
-Hardware Test v4 +Sample Data
warning
-Impact Detected: +4.8G +Road Alert Example

- Emergency SOS countdown initialized. Coordinates resolved. Dispatch pending in: + This preview is illustrative. It does not detect crashes, transmit an SOS, or contact responders.

-
- 00:08s +
+ SAMPLE
- - + + Close Preview + + + Report a Hazard +
@@ -520,12 +508,12 @@ export default function FeaturesPage() { 43.9975° N, 11.3714° E (Mugello S3)
-Rider Profile: -A-Positive // Emergency Contact Linked +Availability: +Sample content only
-Data Sent: -Trail Crash Packet (CAN-Bus 250kbps) +Emergency dispatch: +Not provided
@@ -538,18 +526,22 @@ export default function FeaturesPage() {
-04 // OBD-II & ECU Engine Diagnostics +04 // Phone Sensor Telemetry

- Direct CAN-bus & ECU Health Stream + Phone-Based Ride Telemetry

- Zero splicing or invasive wire taps. Connect MotorSignal CAN-Link directly into your bike's factory diagnostic port to harvest microsecond factory telemetry. + Use your phone's supported sensors to record ride data. OBD-II, CAN-bus, and motorcycle ECU diagnostics are not currently available.

-{/* Engine Diagnostic Sensor Matrix (6 Cards) */} +

+ OBD-II, CAN-bus, and motorcycle ECU diagnostics are not currently available in MotorSignal. Ride recording uses supported phone sensors. +

+{/* Unavailable hardware concepts are intentionally not presented as product capabilities. */} + + {/* SECTION 5: CLOUD SYNC & REPLAY */}
@@ -772,17 +765,17 @@ export default function FeaturesPage() { Ready for Track & Canyon Roads

- Equip your machine with pro-grade telemetry today. + Start recording rides with your phone.

- Start your 30-day all-access trial on iOS & Android, or order the plug-and-play OBD-II hardware link with instant track delivery. + Explore the ride recording and rider community features available in MotorSignal today.

Start 30-Day Free Trial - Order OBD-II Hardware Link + Explore MotorSignal
diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index b218eb4..2a89675 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -13,7 +13,7 @@ export default function PricingPage() { return (
-
+
+
+
+

Subscription plans are not available yet

+

MotorSignal does not currently offer web checkout or sell OBD-II or add-on hardware. You can use the app's available phone-based ride recording and community features.

+
+
diff --git a/website/components/auth-form.tsx b/website/components/auth-form.tsx index 18a34fe..3d7f769 100644 --- a/website/components/auth-form.tsx +++ b/website/components/auth-form.tsx @@ -11,37 +11,49 @@ export function AuthForm({ mode }: { mode: AuthMode }) { const [password, setPassword] = useState(''); const [deviceName, setDeviceName] = useState('Web browser'); const [message, setMessage] = useState(''); + const [googleCanceled, setGoogleCanceled] = useState(false); const [busy, setBusy] = useState(false); const [googleBusy, setGoogleBusy] = useState(false); async function handleGoogleSignIn() { setGoogleBusy(true); setMessage(''); + setGoogleCanceled(false); try { const credential = await signInWithPopup(firebaseAuth, googleProvider); const idToken = await credential.user.getIdToken(); // Exchange firebase idToken with backend to set server session & ops cookies + let response: Response | undefined; try { - await fetch('/api/auth/firebase-login', { + response = await fetch('/api/auth/firebase-login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id_token: idToken }), }); } catch { // Fallback to legacy link if available - try { - await fetch('/api/account/firebase-link', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id_token: idToken }), - }); - } catch { - // Backend link optional; Firebase auth is sufficient for client cloud data + const legacyResponse = await fetch('/api/account/firebase-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id_token: idToken }), + }); + if (!legacyResponse.ok) { + throw new Error('Unable to establish a secure session. Please try again.'); } } + if (response && !response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail ?? 'Unable to establish a secure session. Please try again.'); + } const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account'; window.location.assign(destination); } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + if (code === 'auth/popup-closed-by-user' || code === 'auth/cancelled-popup-request') { + setGoogleCanceled(true); + setMessage('Google sign-in was canceled. You can try again.'); + return; + } console.error('Google Sign-In Error:', error); setMessage(error instanceof Error ? error.message : 'Google sign-in failed. Please try again.'); } finally { @@ -53,6 +65,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) { event.preventDefault(); setBusy(true); setMessage(''); + setGoogleCanceled(false); try { const response = await fetch(`/api/auth/${mode}`, { method: 'POST', @@ -161,7 +174,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) { )} {message && ( -
+
{message}
)} diff --git a/website/components/founder-portal/founder-chat.tsx b/website/components/founder-portal/founder-chat.tsx new file mode 100644 index 0000000..1e54c20 --- /dev/null +++ b/website/components/founder-portal/founder-chat.tsx @@ -0,0 +1,336 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { + type ChatMessage, + subscribeClubChat, + sendClubChatMessage, + FIXTURE_CHAT_MESSAGES, +} from '../../lib/club-community'; +import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data'; + +interface FounderChatProps { + context: FounderClubContext; +} + +type ChannelId = 'general' | 'officers' | 'ride-intel'; + +interface ChannelInfo { + id: ChannelId; + name: string; + icon: string; + description: string; + requiresOfficer: boolean; +} + +const CHANNELS: ChannelInfo[] = [ + { + id: 'general', + name: 'general-squad', + icon: 'chat', + description: 'General discussions, weekend meetups, and squad banter for all club members.', + requiresOfficer: false, + }, + { + id: 'officers', + name: 'officers-lounge', + icon: 'lock', + description: 'Private coordination channel restricted to Club Founder and Admins.', + requiresOfficer: true, + }, + { + id: 'ride-intel', + name: 'road-intel', + icon: 'radar', + description: 'Live pavement scouting, weather warnings, radar alerts, and road hazard reports.', + requiresOfficer: false, + }, +]; + +export function FounderChat({ context }: FounderChatProps) { + const { club, permissions, currentUser, isDemoFixture } = context; + const [activeChannelId, setActiveChannelId] = useState('general'); + const [messages, setMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(null); + + const messagesEndRef = useRef(null); + const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin'; + + const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0]; + const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer; + + useEffect(() => { + setError(null); + let unsubscribe: () => void = () => {}; + + if (isDemoFixture) { + const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || []; + setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId }))); + } else { + unsubscribe = subscribeClubChat( + club.id, + activeChannelId, + (newMsgs) => { + setMessages(newMsgs); + }, + (err) => { + setError(sanitizePortalError(err, 'Failed to connect to chat channel.')); + } + ); + } + + return () => { + unsubscribe(); + }; + }, [club.id, activeChannelId, isDemoFixture]); + + // Auto-scroll on new message + useEffect(() => { + if (typeof messagesEndRef.current?.scrollIntoView === 'function') { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages]); + + async function handleSendMessage(e?: React.FormEvent) { + if (e) e.preventDefault(); + const clean = inputText.trim(); + if (!clean || isSending || isRestrictedChannel) return; + + setIsSending(true); + setError(null); + + try { + if (isDemoFixture) { + const optimisticMsg: ChatMessage = { + id: `demo-msg-${Date.now()}`, + clubId: club.id, + channelId: activeChannelId, + authorUid: currentUser.uid, + authorDisplayName: 'Club Founder', + authorHandle: '@founder', + authorDisplayRank: currentUser.role === 'owner' ? 'President' : 'Admin', + authorRole: currentUser.role, + content: clean, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, optimisticMsg]); + } else { + await sendClubChatMessage(club.id, activeChannelId, clean); + } + setInputText(''); + } catch (err) { + setError(sanitizePortalError(err, 'Failed to send message.')); + } finally { + setIsSending(false); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + } + + return ( +
+ {/* Header Card */} +
+
+
+

+ Squad Live Chat +

+ + + Live Connected + +
+

+ Real-time squad channels for ride organization, road condition warnings, and administrative officer discussions. +

+
+ + {/* Channel quick tabs */} +
+ {CHANNELS.map((ch) => { + const isActive = ch.id === activeChannelId; + const isLocked = ch.requiresOfficer && !isOfficer; + + return ( + + ); + })} +
+
+ + {error && ( +
+ + error + {error} + + +
+ )} + + {/* Chat Room Area */} +
+ {/* Channel Banner */} +
+
+ + {activeChannel.icon} + +
+ + #{activeChannel.name} + +

+ {activeChannel.description} +

+
+
+ +
+ {messages.length} messages +
+
+ + {/* Message Stream */} +
+ {isRestrictedChannel ? ( +
+ lock +

+ Officers Lounge Restricted +

+

+ This channel is strictly reserved for club Founders and Admins. Switch to #general-squad or #road-intel to chat with the club. +

+
+ ) : messages.length === 0 ? ( +
+ chat_bubble_outline +

+ No messages yet in #{activeChannel.name}. Start the conversation! +

+
+ ) : ( + messages.map((msg) => { + const isMe = msg.authorUid === currentUser.uid; + const isOwnerRole = msg.authorRole === 'owner'; + const isAdminRole = msg.authorRole === 'admin'; + + return ( +
+ {/* Avatar / Monogram */} +
+ {msg.authorDisplayName.charAt(0).toUpperCase()} +
+ + {/* Message Bubble */} +
+ {/* Author Meta */} +
+ + {msg.authorDisplayName} + + {msg.authorHandle && ( + + {msg.authorHandle} + + )} + {msg.authorDisplayRank && ( + + {msg.authorDisplayRank} + + )} + + {msg.createdAt + ? new Date(msg.createdAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + : ''} + +
+ + {/* Content */} +

+ {msg.content} +

+
+
+ ); + }) + )} +
+
+ + {/* Message Input Box */} +
+
+ setInputText(e.target.value)} + onKeyDown={handleKeyDown} + maxLength={1000} + className="flex-1 bg-surface-container border border-brand-border/40 rounded-xl px-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60 disabled:opacity-50" + /> + + +
+
+
+
+ ); +} diff --git a/website/components/founder-portal/founder-community-page.tsx b/website/components/founder-portal/founder-community-page.tsx index 1647a22..9b0f156 100644 --- a/website/components/founder-portal/founder-community-page.tsx +++ b/website/components/founder-portal/founder-community-page.tsx @@ -8,10 +8,12 @@ import { fetchFounderClubContext, sanitizePortalError, type FounderClubContext } import { FounderPortalShell } from './founder-portal-shell'; import { FounderMembers } from './founder-members'; import { FounderWall } from './founder-wall'; +import { FounderRoutes } from './founder-routes'; +import { FounderChat } from './founder-chat'; import { OverviewSkeleton } from './skeleton-state'; import { SafeErrorState } from './safe-error-state'; -export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' }) { +export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' | 'routes' | 'chat' }) { const router = useRouter(); const searchParams = useSearchParams(); const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo'; @@ -55,7 +57,9 @@ export function FounderCommunityPage({ clubId, section }: { clubId: string; sect if (section === 'wall') document.getElementById('wall-post-content')?.focus(); else router.push(`/clubs/${clubId}/manage/wall${demo ? '?preview=demo' : ''}#wall-post-content`); }}> - {section === 'members' ? - : } + {section === 'members' && } + {section === 'wall' && } + {section === 'routes' && } + {section === 'chat' && } ; } diff --git a/website/components/founder-portal/founder-routes.tsx b/website/components/founder-portal/founder-routes.tsx new file mode 100644 index 0000000..9c5c2b5 --- /dev/null +++ b/website/components/founder-portal/founder-routes.tsx @@ -0,0 +1,597 @@ +'use client'; + +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import Link from 'next/link'; +import { + type ClubRoute, + fetchClubRoutes, + createClubRoute, + deleteClubRoute, +} from '../../lib/club-community'; +import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data'; + +interface FounderRoutesProps { + context: FounderClubContext; +} + +export function FounderRoutes({ context }: FounderRoutesProps) { + const { club, permissions, currentUser, isDemoFixture } = context; + const [routes, setRoutes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [curvatureFilter, setCurvatureFilter] = useState('all'); + const [surfaceFilter, setSurfaceFilter] = useState('all'); + + // Modal states + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [modalError, setModalError] = useState(null); + const [routeToDelete, setRouteToDelete] = useState(null); + + // New route form state + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [distanceMiles, setDistanceMiles] = useState('35.0'); + const [estimatedMinutes, setEstimatedMinutes] = useState('45'); + const [elevationGainFt, setElevationGainFt] = useState('1500'); + const [curvatureRating, setCurvatureRating] = useState<'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace'>('High Twisties'); + const [surfaceType, setSurfaceType] = useState<'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass'>('Paved Asphalt'); + const [startLocation, setStartLocation] = useState(''); + const [endLocation, setEndLocation] = useState(''); + + const modalRef = useRef(null); + + useEffect(() => { + let active = true; + async function loadRoutes() { + setLoading(true); + setError(null); + try { + const data = await fetchClubRoutes(club.id, { allowDemoPreview: isDemoFixture }); + if (active) setRoutes(data); + } catch (err) { + if (active) setError(sanitizePortalError(err, 'Failed to load club routes.')); + } finally { + if (active) setLoading(false); + } + } + loadRoutes(); + return () => { + active = false; + }; + }, [club.id, isDemoFixture]); + + const filteredRoutes = useMemo(() => { + return routes.filter((r) => { + const matchesSearch = + !searchQuery.trim() || + r.title.toLowerCase().includes(searchQuery.toLowerCase()) || + r.description.toLowerCase().includes(searchQuery.toLowerCase()) || + r.curatorName.toLowerCase().includes(searchQuery.toLowerCase()) || + r.startLocation.toLowerCase().includes(searchQuery.toLowerCase()) || + r.endLocation.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesCurvature = + curvatureFilter === 'all' || r.curvatureRating === curvatureFilter; + + const matchesSurface = + surfaceFilter === 'all' || r.surfaceType === surfaceFilter; + + return matchesSearch && matchesCurvature && matchesSurface; + }); + }, [routes, searchQuery, curvatureFilter, surfaceFilter]); + + async function handleAddRoute(e: React.FormEvent) { + e.preventDefault(); + if (!title.trim() || !startLocation.trim() || !endLocation.trim()) { + setModalError('Title, start location, and end location are required.'); + return; + } + + setIsSubmitting(true); + setModalError(null); + try { + if (isDemoFixture) { + const newDemoRoute: ClubRoute = { + id: `demo-route-${Date.now()}`, + clubId: club.id, + title: title.trim(), + description: description.trim(), + distanceMiles: parseFloat(distanceMiles) || 20, + estimatedMinutes: parseInt(estimatedMinutes, 10) || 30, + elevationGainFt: parseInt(elevationGainFt, 10) || 500, + curvatureRating, + surfaceType, + startLocation: startLocation.trim(), + endLocation: endLocation.trim(), + curatorUid: currentUser.uid, + curatorName: 'Club Founder', + createdAt: new Date().toISOString(), + }; + setRoutes([newDemoRoute, ...routes]); + } else { + await createClubRoute(club.id, { + title: title.trim(), + description: description.trim(), + distanceMiles: parseFloat(distanceMiles) || 20, + estimatedMinutes: parseInt(estimatedMinutes, 10) || 30, + elevationGainFt: parseInt(elevationGainFt, 10) || 500, + curvatureRating, + surfaceType, + startLocation: startLocation.trim(), + endLocation: endLocation.trim(), + }); + const updated = await fetchClubRoutes(club.id); + setRoutes(updated); + } + + setIsAddModalOpen(false); + setTitle(''); + setDescription(''); + setStartLocation(''); + setEndLocation(''); + } catch (err) { + setModalError(sanitizePortalError(err, 'Failed to save route.')); + } finally { + setIsSubmitting(false); + } + } + + async function handleDeleteRoute(routeId: string) { + try { + if (isDemoFixture) { + setRoutes((prev) => prev.filter((r) => r.id !== routeId)); + } else { + await deleteClubRoute(club.id, routeId); + setRoutes((prev) => prev.filter((r) => r.id !== routeId)); + } + setRouteToDelete(null); + } catch (err) { + setError(sanitizePortalError(err, 'Failed to delete route.')); + } + } + + function downloadGpx(route: ClubRoute) { + const gpxContent = ` + + + ${route.title} + ${route.description} + + + ${route.title} + ${route.startLocation} + ${route.endLocation} + +`; + const blob = new Blob([gpxContent], { type: 'application/gpx+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${route.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.gpx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + const canCurate = permissions.canPlanRides || permissions.canManageSettings; + + return ( +
+ {/* Top Header Card */} +
+
+
+

+ Curated Club Routes +

+ + {routes.length} Available + +
+

+ Curate twisty runs, elevation passes, and scenic loops for club group rides. Export GPX files directly for offline GPS units and cockpit navigation. +

+
+ + {canCurate && ( + + )} +
+ + {error && ( +
+ + error + {error} + + +
+ )} + + {/* Filter and Search Controls */} +
+
+ + search + + setSearchQuery(e.target.value)} + className="w-full bg-surface-container/60 border border-brand-border/40 rounded-xl pl-9 pr-4 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60" + /> +
+ +
+ {/* Curvature Filter */} + + + {/* Surface Filter */} + +
+
+ + {/* Routes List Grid */} + {loading ? ( +
+ {[1, 2, 3].map((n) => ( +
+
+
+
+
+ ))} +
+ ) : filteredRoutes.length === 0 ? ( +
+ route +

No Routes Found

+

+ {searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all' + ? 'No routes match your search or filter criteria. Try resetting your filters.' + : 'No club routes have been curated yet. Be the first to add a route for upcoming club rides!'} +

+ {(searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all') && ( + + )} +
+ ) : ( +
+ {filteredRoutes.map((route) => { + const isHighCurvature = route.curvatureRating === 'High Twisties'; + const isModerate = route.curvatureRating === 'Moderate'; + + return ( +
+
+ {/* Card Header & Badge */} +
+

+ {route.title} +

+ + {route.curvatureRating} + +
+ +

+ {route.description} +

+ + {/* Waypoint Strip */} +
+ + trip_origin + + {route.startLocation} + ➔ + {route.endLocation} +
+ + {/* Metrics Bar */} +
+
+
Distance
+
{route.distanceMiles} mi
+
+
+
Est. Time
+
{route.estimatedMinutes} min
+
+
+
Climb
+
+{route.elevationGainFt} ft
+
+
+ +
+ + terrain + {route.surfaceType} + + By {route.curatorName} +
+
+ + {/* Card Actions */} +
+ + +
+ + add_road + Stage Ride + + + {canCurate && ( + + )} +
+
+
+ ); + })} +
+ )} + + {/* Curate Route Modal */} + {isAddModalOpen && ( +
+
+
+
+ add_road +

Curate Club Route

+
+ +
+ + {modalError && ( +
+ {modalError} +
+ )} + +
+
+ + setTitle(e.target.value)} + className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60" + /> +
+ +
+ +