From d5dcc9749585fbf7ceae36cf720804422640e4e3 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 18 Sep 2026 18:35:31 -0400 Subject: [PATCH] feat(website): standardize dark glass design system, obsidian contrast buttons, and rider account management --- website/app/account/page.tsx | 398 +++++- .../app/api/account/firebase-link/route.ts | 25 + .../api/account/sessions/[sessionId]/route.ts | 11 + website/app/api/account/sessions/route.ts | 13 + website/app/api/auth/[action]/route.ts | 49 +- website/app/api/motorcycle-image/route.ts | 236 ++++ website/app/features/page.tsx | 190 +-- website/app/forgot-password/page.tsx | 34 +- website/app/globals.css | 27 +- website/app/group-rides/page.tsx | 214 ++-- website/app/layout.tsx | 43 +- website/app/login/page.tsx | 38 +- website/app/page.tsx | 2 +- website/app/pricing/page.tsx | 515 +++++++- website/app/register/page.tsx | 36 +- website/components/app-cloud-data.tsx | 1058 ++++++++++++++++ website/components/auth-form.tsx | 159 ++- website/components/firebase-link.tsx | 28 + website/components/landing/HeroSection.tsx | 32 +- website/components/recovery-form.tsx | 4 +- website/components/session-manager.tsx | 29 + website/components/sign-out-button.tsx | 21 + website/components/site-footer.tsx | 19 +- website/components/site-header-client.tsx | 220 +++- website/components/site-header.tsx | 33 +- website/lib/firebase-client.ts | 16 + website/package-lock.json | 1079 ++++++++++++++++- website/package.json | 1 + website/tests/navigation-auth.cjs | 60 + website/tests/responsive-layout.test.cjs | 102 ++ 30 files changed, 4358 insertions(+), 334 deletions(-) create mode 100644 website/app/api/account/firebase-link/route.ts create mode 100644 website/app/api/account/sessions/[sessionId]/route.ts create mode 100644 website/app/api/account/sessions/route.ts create mode 100644 website/app/api/motorcycle-image/route.ts create mode 100644 website/components/app-cloud-data.tsx create mode 100644 website/components/firebase-link.tsx create mode 100644 website/components/session-manager.tsx create mode 100644 website/components/sign-out-button.tsx create mode 100644 website/lib/firebase-client.ts create mode 100644 website/tests/navigation-auth.cjs create mode 100644 website/tests/responsive-layout.test.cjs diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index 221081b..9dce1b3 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -1,32 +1,378 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; import Link from 'next/link'; -import { cookies } from 'next/headers'; +import { onAuthStateChanged, User } from 'firebase/auth'; +import { firebaseAuth } from '../../lib/firebase-client'; +import { AppCloudData } from '../../components/app-cloud-data'; +import { SessionManager } from '../../components/session-manager'; +import { FirebaseLink } from '../../components/firebase-link'; +import { SignOutButton } from '../../components/sign-out-button'; -type CurrentUser = { id: string; email: string; email_verified: boolean }; -type Vehicle = { id: string; nickname: string; make: string; model: string; year?: number | null }; -type Ride = { id: string; started_at: string; distance_miles?: number | null; top_speed_mph?: number | null; status: string }; -type Subscription = { plan: string; status: string }; +export default function AccountPage() { + const [user, setUser] = useState(null); + const [authChecked, setAuthChecked] = useState(false); + 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 }); -async function loadAccount(): Promise<{ user: CurrentUser; vehicles: Vehicle[]; rides: Ride[]; subscription: Subscription } | null> { - const token = (await cookies()).get('motorsignal_access_token')?.value; - if (!token) return null; - const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; - try { - const response = await fetch(`${baseUrl}/api/v1/me`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }); - if (!response.ok) return null; - const user = await response.json() as CurrentUser; - const [vehiclesResponse, ridesResponse, subscriptionResponse] = await Promise.all([ - fetch(`${baseUrl}/api/v1/vehicles`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }), - fetch(`${baseUrl}/api/v1/rides?page=1&page_size=5`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }), - fetch(`${baseUrl}/api/v1/me/subscription`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }), - ]); - return { user, vehicles: vehiclesResponse.ok ? await vehiclesResponse.json() as Vehicle[] : [], rides: ridesResponse.ok ? (await ridesResponse.json()).items as Ride[] : [], subscription: subscriptionResponse.ok ? await subscriptionResponse.json() as Subscription : { plan: 'free', status: 'unknown' } }; - } catch { - return null; + useEffect(() => { + const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => { + setUser(currentUser); + setAuthChecked(true); + }); + return () => unsubscribe(); + }, []); + + function handleQuickSync() { + setIsSyncing(true); + setSyncTrigger((prev) => prev + 1); } -} -export default async function AccountPage() { - const account = await loadAccount(); - if (!account) return
Account portal

Sign in to your rider space.

Manage your MotorSignal account, security, vehicles, and ride history from one place.

Sign inCreate account
; - return
Account portal

Welcome back.

{account.user.email}

Profile

Account status: Active

Email verification: {account.user.email_verified ? 'Verified' : 'Pending'}

Plan

{account.subscription.plan} · {account.subscription.status}

Vehicles

{account.vehicles.length ? account.vehicles.map((vehicle) =>

{vehicle.nickname} · {vehicle.year ?? ''} {vehicle.make} {vehicle.model}

) :

No vehicles yet.

}

Recent rides

{account.rides.length ? account.rides.map((ride) =>

{new Date(ride.started_at).toLocaleDateString()} · {ride.distance_miles ?? 0} mi · {ride.status}

) :

No rides yet.

}
; + const initials = user?.displayName + ? user.displayName.split(' ').map((n) => n[0]).join('').slice(0, 2).toUpperCase() + : (user?.email ? user.email.slice(0, 2).toUpperCase() : 'MS'); + return ( +
+ +
+{/* BEGIN: HeroIdentitySection */} +
+{/* Top aesthetic accent line (Red Area Fixed) */} +
+
+{/* Rider Info */} +
+{/* Rider Avatar / Helmet Node */} +
+
+{initials} +
+ + + +
+{/* Titles & Metadata */} +
+
+ +Rider Portal // Telemetry Cockpit +
+

+ {user ? `Welcome back, ${user.displayName || user.email?.split('@')[0] || 'Rider'}.` : 'Rider Account & Cloud Telemetry'} +

+
+{user?.email || 'Guest Session (Not Signed In)'} +• + + + {user ? 'Pro Telemetry Tier' : 'Guest Tier'} + +• +Status: {user ? 'Active Rider' : 'Signed Out'} +
+
+
+{/* Quick Action Buttons (Green Area Fixed) */} +
+{user ? ( + <> + +
+ +
+ +) : ( + <> + + Sign In + + + Register + + +)} +
+
+
+{/* END: HeroIdentitySection */} +{/* BEGIN: TelemetryKpiGrid */} +
+{/* Metric 1: Distance */} +
+
+Total Distance Logged +Cloud Aggregate +
+
+{metrics.totalMiles.toLocaleString()} +miles +
+
+ +All-time telemetry aggregate +
+
+{/* Metric 2: Sessions */} +
+
+Recorded Sessions +100% Cloud Synced +
+
+{metrics.rideCount} +rides +
+
+Canyons, Circuits & Commutes +
+
+{/* Metric 3: Lean Angle Peak */} +
+
+Peak Lean Angle +Lateral Roll +
+
+{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} +lateral roll +
+
+6-Axis IMU Verified +
+
+{/* Metric 4: Hardware Link */} +
+
+Hardware Telemetry + +
+
+OBD-II CAN v3.2 +
+
+Active Sync @ 10Hz Multi-GNSS +
+
+
+{/* END: TelemetryKpiGrid */} + +{/* Live App Cloud Data (Vehicle Garage, Telemetry Hardware & Recent Rides from Firestore) */} + { + setIsSyncing(syncing); + if (!syncing) { + setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + } + }} + onMetricsLoaded={(m) => setMetrics(m)} +/> +{/* BEGIN: PlanAndClusterSection */} +
+{/* Left 7 cols: Plan & Hardware Dongle */} +
+
+
+
+Active Subscription Plan +

+Pro Telemetry Tier +Active • Renews Sept 2027 +

+
+$189/yr billed +
+{/* Inclusions Pills */} +
+Entitlements & Features +
+ + +10Hz Multi-GNSS Logging + + + +ECU CAN Bus Full Trace + + + +Unlimited Cloud Storage + + + +Crash SOS Beacon (<150ms) + +
+
+{/* Linked Hardware Details */} +
+
+Registered Dongle +MotorSignal CAN-Link OBD-II +
SN: MS-LINK-9021-X
+
+
+Firmware Status +v2.4.1 (Current) +
Last Check: Today, 09:12 AM
+
+
+
+
+ + shield + Founder Portal + + + +
+
+{/* Right 5 cols: Connected Apps & Cluster Sync */} +
+
+
+App Connections & Sync + + +All Synced + +
+{/* Sync List */} +
+{/* Google App Account */} +
+
+
+ G +
+
+Google Account +bponders12@gmail.com +
+
+Connected +
+{/* MotorSignal Mobile App */} +
+
+
+ +
+
+iOS Mobile App +iPhone 16 Pro • v4.1.0 +
+
+14m ago +
+{/* Comms / Cardo */} +
+
+
+ +
+
+Helmet Comms Link +Cardo Packtalk Edge +
+
+Paired +
+
+
+
+Apple Watch Companion: Active (HRM Sync) + +
+
+
+{/* END: PlanAndClusterSection */} +{/* BEGIN: SecurityAndSessionsGrid */} +
+{/* Sessions (7 cols) */} +
+
+
+

Active Device Sessions

+

Authenticated tokens accessing MotorSignal account telemetry

+
+
+ +
+{/* Security & SOS Preferences (5 cols) */} +
+
+
+

Security & Rider SOS

+

Emergency contact routing and app account link

+
+
+{/* Google App Link */} +
+
+Mobile App Account Link + + {user ? 'CONNECTED' : 'NOT LINKED'} + +
+ +
+{/* 2FA State */} +
+
+Two-Factor Authentication (2FA) +Hardware TOTP Authenticator +
+ENABLED +
+{/* Leaderboard Privacy */} +
+
+Telemetry Privacy Mode +Public Leaderboard (Club Trophies) +
+ +
+
+
+
+Telemetry Data Retention: 100% Retained +Security Logs +
+
+
+{/* END: SecurityAndSessionsGrid */} +
+ +
+ ); } diff --git a/website/app/api/account/firebase-link/route.ts b/website/app/api/account/firebase-link/route.ts new file mode 100644 index 0000000..bf19ca6 --- /dev/null +++ b/website/app/api/account/firebase-link/route.ts @@ -0,0 +1,25 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../lib/request-origin'; + +export async function POST(request: NextRequest) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + const accessToken = (await cookies()).get('motorsignal_access_token')?.value; + if (!accessToken) return NextResponse.json({ detail: 'Sign in required.' }, { status: 401 }); + try { + const upstream = await fetch(`${process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'}/api/v1/auth/firebase/link`, { + method: 'POST', + headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, + body: JSON.stringify(await request.json()), + cache: 'no-store', + }); + return new NextResponse(await upstream.text(), { + status: upstream.status, + headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' }, + }); + } catch { + return NextResponse.json({ detail: 'Firebase linking is unavailable.' }, { status: 503 }); + } +} diff --git a/website/app/api/account/sessions/[sessionId]/route.ts b/website/app/api/account/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..82ca18c --- /dev/null +++ b/website/app/api/account/sessions/[sessionId]/route.ts @@ -0,0 +1,11 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function DELETE(request: NextRequest, context: { params: Promise<{ sessionId: string }> }) { + const token = (await cookies()).get('motorsignal_access_token')?.value; + if (!token) return NextResponse.json({ detail: 'Authentication required.' }, { status: 401 }); + const { sessionId } = await context.params; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const response = await fetch(`${baseUrl}/api/v1/auth/sessions/${sessionId}`, { method: 'DELETE', headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }); + return new NextResponse(await response.text(), { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' } }); +} diff --git a/website/app/api/account/sessions/route.ts b/website/app/api/account/sessions/route.ts new file mode 100644 index 0000000..7566694 --- /dev/null +++ b/website/app/api/account/sessions/route.ts @@ -0,0 +1,13 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +async function forward(request: NextRequest, method: 'GET' | 'POST') { + const token = (await cookies()).get('motorsignal_access_token')?.value; + if (!token) return NextResponse.json({ detail: 'Authentication required.' }, { status: 401 }); + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const response = await fetch(`${baseUrl}/api/v1/auth/${method === 'GET' ? 'sessions' : 'logout-all'}`, { method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: method === 'POST' ? '{}' : undefined, cache: 'no-store' }); + return new NextResponse(await response.text(), { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' } }); +} + +export async function GET(request: NextRequest) { return forward(request, 'GET'); } +export async function POST(request: NextRequest) { return forward(request, 'POST'); } diff --git a/website/app/api/auth/[action]/route.ts b/website/app/api/auth/[action]/route.ts index 84ccc10..377f944 100644 --- a/website/app/api/auth/[action]/route.ts +++ b/website/app/api/auth/[action]/route.ts @@ -1,13 +1,37 @@ import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { hasValidOrigin } from '../../../../lib/request-origin'; -const actions = new Set(['login', 'register', 'refresh', 'logout', 'verify-email-request', 'verify-email', 'password-reset-request', 'password-reset']); +const actions = new Set(['login', 'register', 'refresh', 'logout', 'verify-email-request', 'verify-email', 'password-reset-request', 'password-reset', 'firebase-login']); export async function POST(request: NextRequest, context: { params: Promise<{ action: string }> }) { const { action } = await context.params; if (!actions.has(action)) return NextResponse.json({ detail: 'Unknown authentication action.' }, { status: 404 }); + if (action === 'logout') { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + const refreshToken = (await cookies()).get('motorsignal_refresh_token')?.value; + if (refreshToken) { + try { + const upstream = await fetch(`${process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'}/api/v1/auth/logout`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), cache: 'no-store', + }); + if (!upstream.ok) return NextResponse.json({ detail: 'Unable to revoke session.' }, { status: 502 }); + } catch { + return NextResponse.json({ detail: 'Unable to revoke session.' }, { status: 502 }); + } + } + const response = new NextResponse(null, { status: 204 }); + response.cookies.delete('motorsignal_access_token'); + response.cookies.delete('motorsignal_refresh_token'); + return response; + } const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; const endpoint = action === 'verify-email-request' ? 'verify-email/request' : action === 'password-reset-request' ? 'password-reset/request' + : action === 'firebase-login' ? 'firebase/login' : action; const upstream = await fetch(`${baseUrl}/api/v1/auth/${endpoint}`, { method: 'POST', @@ -16,20 +40,37 @@ export async function POST(request: NextRequest, context: { params: Promise<{ ac cache: 'no-store', }); const body = await upstream.text(); - const response = new NextResponse(body, { status: upstream.status, headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' } }); + const response = new NextResponse(upstream.status === 204 ? null : body, { status: upstream.status, headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' } }); if (action === 'logout') { response.cookies.delete('motorsignal_access_token'); response.cookies.delete('motorsignal_refresh_token'); + response.cookies.delete('__Host-motorsignal_ops'); + response.cookies.delete('motorsignal_ops'); } else if (upstream.ok) { - let payload: { access_token?: string; refresh_token?: string } = {}; + let payload: { access_token?: string; refresh_token?: string; ops_token?: string } = {}; try { - payload = JSON.parse(body) as { access_token?: string; refresh_token?: string }; + payload = JSON.parse(body) as { access_token?: string; refresh_token?: string; ops_token?: string }; } catch { return response; } const cookieOptions = { httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/', maxAge: 60 * 60 * 24 * 30 }; if (payload.access_token) response.cookies.set('motorsignal_access_token', payload.access_token, cookieOptions); if (payload.refresh_token) response.cookies.set('motorsignal_refresh_token', payload.refresh_token, cookieOptions); + if (payload.ops_token) { + const opsCookieOptions = { + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + path: '/', + maxAge: 60 * 60 * 8, + }; + response.cookies.set('__Host-motorsignal_ops', payload.ops_token, opsCookieOptions); + response.cookies.set('motorsignal_ops', payload.ops_token, { + ...opsCookieOptions, + secure: process.env.NODE_ENV === 'production', + }); + } } return response; } + diff --git a/website/app/api/motorcycle-image/route.ts b/website/app/api/motorcycle-image/route.ts new file mode 100644 index 0000000..2496263 --- /dev/null +++ b/website/app/api/motorcycle-image/route.ts @@ -0,0 +1,236 @@ +import { NextRequest, NextResponse } from 'next/server'; + +interface ResolvedMotorcycleImage { + imageUrl: string; + caption: string; + source: 'google' | 'curated' | 'wikimedia' | 'wikipedia'; + attribution: string; +} + +// Curated high-resolution stock photography for flagship motorcycles +const CURATED_IMAGES: Record = { + 'yamaha_yzf-r6_2001': { + imageUrl: + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e0/2001_Yamaha_YZFR600.jpg/960px-2001_Yamaha_YZFR600.jpg', + caption: '2001 Yamaha YZF-R6 (5EB)', + source: 'curated', + attribution: 'Wikimedia Commons / CC BY', + }, + 'yamaha_yzf-r6': { + imageUrl: + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/d/dd/Yamaha_YZF-R6_Cropped.jpg/960px-Yamaha_YZF-R6_Cropped.jpg', + caption: 'Yamaha YZF-R6 Supersport', + source: 'curated', + attribution: 'Wikimedia Commons / CC BY', + }, + 'ducati_panigale-v4-s': { + imageUrl: + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/a7/Ducati_Panigale_V4_S_Corse.jpg/960px-Ducati_Panigale_V4_S_Corse.jpg', + caption: 'Ducati Panigale V4 S Corse', + source: 'curated', + attribution: 'Wikimedia Commons / CC BY', + }, + 'ducati_panigale-v4': { + imageUrl: + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/a8/Ducati_Panigale_V4_-_Tuning_World_Bodensee_2018%2C_Friedrichshafen_%28OW1A0484%29.jpg/3840px-Ducati_Panigale_V4_-_Tuning_World_Bodensee_2018%2C_Friedrichshafen_%28OW1A0484%29.jpg', + caption: 'Ducati Panigale V4', + source: 'curated', + attribution: 'Wikimedia Commons / CC BY', + }, + 'bmw_s1000rr': { + imageUrl: + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/a0/2022-05_BMW_M_Motorrad_S1000RR.jpg/960px-2022-05_BMW_M_Motorrad_S1000RR.jpg', + caption: 'BMW S1000RR Superbike', + source: 'curated', + attribution: 'Wikimedia Commons / CC BY', + }, + 'honda_cbr600rr': { + imageUrl: + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/6/64/2006HondaCBR600RR-001.jpg/960px-2006HondaCBR600RR-001.jpg', + caption: 'Honda CBR600RR', + source: 'curated', + attribution: 'Wikimedia Commons / CC BY', + }, + 'kawasaki_ninja-zx-6r': { + imageUrl: + 'https://thumb.wikimedia.org/wikipedia/commons/thumb/9/93/2009_Kawasaki_Ninja_ZX-6R_in_white.jpg/960px-2009_Kawasaki_Ninja_ZX-6R_in_white.jpg', + caption: 'Kawasaki Ninja ZX-6R', + source: 'curated', + attribution: 'Wikimedia Commons / CC BY', + }, +}; + +function normalizeKey(make: string, model: string, year?: number): string { + const normMake = make.toLowerCase().replace(/[^a-z0-9]/g, ''); + const normModel = model.toLowerCase().replace(/standard/g, '').replace(/[^a-z0-9]/g, ''); + return year ? `${normMake}_${normModel}_${year}` : `${normMake}_${normModel}`; +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const make = (searchParams.get('make') || '').trim(); + const rawModel = (searchParams.get('model') || '').trim(); + const yearParam = searchParams.get('year'); + const year = yearParam ? parseInt(yearParam, 10) : undefined; + + if (!make && !rawModel) { + return NextResponse.json({ error: 'Make and model are required' }, { status: 400 }); + } + + // Clean model name (remove "Standard", "Base", etc.) + const cleanModel = rawModel + .replace(/\bstandard\b/gi, '') + .replace(/\bbase\b/gi, '') + .trim(); + + // Tier 1: Check curated exact and model cache + if (year) { + const exactCurated = CURATED_IMAGES[normalizeKey(make, cleanModel, year)]; + if (exactCurated) { + return NextResponse.json(exactCurated, { + headers: { 'Cache-Control': 'public, s-maxage=604800, stale-while-revalidate=86400' }, + }); + } + } + + const modelCurated = + CURATED_IMAGES[normalizeKey(make, cleanModel)] || + CURATED_IMAGES[cleanModel.toLowerCase().replace(/[^a-z0-9]/g, '')]; + if (modelCurated) { + return NextResponse.json(modelCurated, { + headers: { 'Cache-Control': 'public, s-maxage=604800, stale-while-revalidate=86400' }, + }); + } + + // Tier 2: Google Custom Search API (if configured in environment) + const googleKey = process.env.GOOGLE_SEARCH_API_KEY; + const googleCx = process.env.GOOGLE_SEARCH_ENGINE_ID; + if (googleKey && googleCx) { + try { + const q = [year, make, cleanModel, 'motorcycle stock photo'].filter(Boolean).join(' '); + const googleUrl = `https://www.googleapis.com/customsearch/v1?key=${encodeURIComponent( + googleKey + )}&cx=${encodeURIComponent(googleCx)}&q=${encodeURIComponent(q)}&searchType=image&num=3&safe=active`; + + const gRes = await fetch(googleUrl, { next: { revalidate: 604800 } }); + if (gRes.ok) { + const gData = await gRes.json(); + const firstItem = gData.items?.[0]; + if (firstItem?.link) { + const result: ResolvedMotorcycleImage = { + imageUrl: firstItem.link, + caption: firstItem.title || `${year ? year + ' ' : ''}${make} ${cleanModel}`, + source: 'google', + attribution: firstItem.displayLink || 'Google Image Search', + }; + return NextResponse.json(result, { + headers: { 'Cache-Control': 'public, s-maxage=604800, stale-while-revalidate=86400' }, + }); + } + } + } catch (err) { + console.warn('Google Custom Search API error, falling back:', err); + } + } + + // Tier 3: Wikimedia Commons API Search + const searchQueries: string[] = []; + if (year && year > 1900) { + searchQueries.push(`${year} ${make} ${cleanModel}`); + } + searchQueries.push(`${make} ${cleanModel}`); + searchQueries.push(`${cleanModel}`); + + for (const query of searchQueries) { + try { + const commonsUrl = `https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrsearch=${encodeURIComponent( + query + )}&gsrlimit=6&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=960`; + + const cRes = await fetch(commonsUrl, { + headers: { + 'User-Agent': 'MotorSignal/1.0 (https://motorsignal.app; contact@motorsignal.app)', + }, + next: { revalidate: 604800 }, + }); + + if (cRes.ok) { + const cData = await cRes.json(); + const pages = cData.query?.pages; + if (pages) { + for (const rawPage of Object.values(pages) as any[]) { + const title = (rawPage.title || '').toLowerCase(); + if ( + title.includes('logo') || + title.includes('icon') || + title.includes('diagram') || + title.includes('flag') || + title.includes('badge') + ) { + continue; + } + + const info = rawPage.imageinfo?.[0]; + const imgUrl = info?.thumburl || info?.url; + if (imgUrl && /\.(jpe?g|png|webp)/i.test(imgUrl)) { + const result: ResolvedMotorcycleImage = { + imageUrl: imgUrl, + caption: `${year ? year + ' ' : ''}${make} ${cleanModel}`, + source: 'wikimedia', + attribution: 'Wikimedia Commons / CC', + }; + return NextResponse.json(result, { + headers: { + 'Cache-Control': 'public, s-maxage=604800, stale-while-revalidate=86400', + }, + }); + } + } + } + } + } catch (err) { + console.warn('Wikimedia query error:', err); + } + } + + // Tier 4: Wikipedia Lead Summary Image + const wikiTitles = [ + `${make} ${cleanModel}`, + cleanModel, + `${make}_${cleanModel}`.replace(/\s+/g, '_'), + ]; + + for (const title of wikiTitles) { + try { + const wikiUrl = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`; + const wRes = await fetch(wikiUrl, { + headers: { + 'User-Agent': 'MotorSignal/1.0 (https://motorsignal.app; contact@motorsignal.app)', + }, + next: { revalidate: 604800 }, + }); + + if (wRes.ok) { + const wData = await wRes.json(); + const img = wData.originalimage?.source || wData.thumbnail?.source; + if (img && !img.includes('disambig') && !img.includes('Question_book')) { + const result: ResolvedMotorcycleImage = { + imageUrl: img, + caption: wData.title || `${year ? year + ' ' : ''}${make} ${cleanModel}`, + source: 'wikipedia', + attribution: 'Wikipedia Lead Reference', + }; + return NextResponse.json(result, { + headers: { + 'Cache-Control': 'public, s-maxage=604800, stale-while-revalidate=86400', + }, + }); + } + } + } catch (err) { + console.warn('Wikipedia query error:', err); + } + } + + return NextResponse.json({ error: 'No stock photo found for this motorcycle' }, { status: 404 }); +} diff --git a/website/app/features/page.tsx b/website/app/features/page.tsx index 4fda3a5..7a25db3 100644 --- a/website/app/features/page.tsx +++ b/website/app/features/page.tsx @@ -55,12 +55,12 @@ export default function FeaturesPage() { -COMPREHENSIVE TELEMETRY STACK • 10HZ MULTI-GNSS & 6-AXIS IMU +Comprehensive Telemetry Stack • 10Hz Multi-GNSS & 6-Axis IMU {/* Main Headline & Subheading */}
-

+

Surgical data precision for every apex, straightaway, and canyon.

@@ -70,12 +70,12 @@ export default function FeaturesPage() {

- HARDWARE LINK RATE - SYNCHRONIZED + Hardware Link Rate + Synchronized
1000 - SAMPLES / SEC (IMU) + Samples / Sec (IMU)
@@ -85,19 +85,19 @@ export default function FeaturesPage() {
{/* Quick Fast Anchor Pills */} @@ -110,10 +110,10 @@ export default function FeaturesPage() {
- 01 // TACTICAL FLIGHT INTERFACE + 01 // Tactical Flight Interface
-

+

Live Cockpit & Handlebar HUD

@@ -131,21 +131,21 @@ export default function FeaturesPage() {
- ACTIVE RECORDING + Active Recording -PORTIMAO CIRCUIT // S2 APEX 4 +Portimao Circuit // S2 Apex 4
-LATENCY: 9.4ms +Latency: 9.4ms • -10Hz GNSS LOCK (18 SATS) +10Hz GNSS Lock (18 Sats)
{/* Central HUD Layout Simulation */}
{/* Left Dial: Lean Angle */}
-Lean Angle (Apex Peak) +Lean Angle (Apex Peak) {/* SVG Arc Lean Angle Dial */}
@@ -154,18 +154,18 @@ export default function FeaturesPage() {
52.8° -L PEAK PEGGED +L Peak Pegged
R 48.2° -LIMIT: 65° +Limit: 65° L 52.8°
{/* Center Speed & G-Force Primary Indicator */}
-Velocity Vector +Velocity Vector
174 MPH @@ -173,7 +173,7 @@ export default function FeaturesPage() { {/* G-Force Crosshair Minimal Widget */}
-LATERAL G +Lateral G +1.68 G
@@ -182,19 +182,19 @@ export default function FeaturesPage() {
-DECEL G +Decel G -1.32 G
{/* Right: Throttle & Hydraulic Brake Line Pressure */}
-Hydraulic & TPS Telemetry +Hydraulic & TPS Telemetry {/* Throttle Bar */}
-THROTTLE TPS -88% OPEN +Throttle TPS +88% Open
@@ -203,8 +203,8 @@ export default function FeaturesPage() { {/* Front Brake Line Pressure */}
-FRONT BRAKE (BAR) -18.4 / 30 BAR +Front Brake (bar) +18.4 / 30 bar
@@ -213,8 +213,8 @@ export default function FeaturesPage() { {/* Rear Brake Line Pressure */}
-REAR TRAIL BRAKE -3.8 BAR +Rear Trail Brake +3.8 bar
@@ -225,8 +225,8 @@ export default function FeaturesPage() { {/* Bottom Live Sparkline Telemetry Wave */}
-THROTTLE vs DECEL TIMELINE (LAST 800M) -TURN 3 ENTRY > APEX 4 BRAKING DISCHARGE +Throttle vs Decel Timeline (Last 800m) +Turn 3 Entry > Apex 4 Braking Discharge
{/* Inline SVG Sparkline */} @@ -241,8 +241,8 @@ export default function FeaturesPage() {
-CNC Billet Stem Mount -IP68 WATERPROOF +CNC Billet Stem Mount +IP68 Waterproof
{/* Key Spec List */} @@ -263,8 +263,8 @@ export default function FeaturesPage() {
-NATIVE MOUNT SUPPORT -QUADLOCK / RAM / SP CONNECT +Native Mount Support +QuadLock / RAM / SP Connect
@@ -277,10 +277,10 @@ export default function FeaturesPage() {
-02 // CHRONOMETRIC RACE ANALYSIS +02 // Chronometric Race Analysis
-

+

Deep Sector Lap & Ride Intelligence

@@ -295,7 +295,7 @@ export default function FeaturesPage() {
Mugello Circuit (Autodromo) -AUTO-DETECTED +Auto-Detected

5.245 km • 15 Turns • 1,141m Long Straightaway. 10Hz multi-satellite track boundary matching. @@ -323,19 +323,19 @@ export default function FeaturesPage() {

- SPEED TRAP: 312.4 KM/H + Speed Trap: 312.4 km/h
- APEX 9 PEAK: 49.6° LEAN + Apex 9 Peak: 49.6° Lean
-BEST LAP TIME +Best Lap Time 1:48.342
-OPTIMAL SECTOR SUM +Optimal Sector Sum 1:47.809
@@ -346,7 +346,7 @@ export default function FeaturesPage() {
Sector Delta Matrix (Lap 7 vs Best Lap 4) --0.533s AHEAD +-0.533s Ahead
{/* Sector 1 */} @@ -428,10 +428,10 @@ export default function FeaturesPage() { {/* Left Explanatory Text & Certs (6 cols) */}
-03 // BLACKBOX PROTOCOL & LIFE SAFETY +03 // Blackbox Protocol & Life Safety
-

+

Emergency Impact Detection & SOS Beacon

@@ -477,11 +477,11 @@ export default function FeaturesPage() {

verified -FIM Telemetry Spec 2025 +FIM Telemetry Spec 2025
shield -ISO 26262 ASIL-B Safety +ISO 26262 ASIL-B Safety
@@ -490,15 +490,15 @@ export default function FeaturesPage() {
-TACTICAL CRASH SIMULATOR DEMO +Tactical Crash Simulator Demo
-HARDWARE TEST V4 +Hardware Test v4
warning
-IMPACT DETECTED: +4.8G +Impact Detected: +4.8G

Emergency SOS countdown initialized. Coordinates resolved. Dispatch pending in:

@@ -516,16 +516,16 @@ export default function FeaturesPage() {
-LAT/LONG: +Lat/Long: 43.9975° N, 11.3714° E (Mugello S3)
-RIDER PROFILE: -A-POSITIVE // EMERGENCY CONTACT LINKED +Rider Profile: +A-Positive // Emergency Contact Linked
-DATA SENT: -TRAIL CRASH PACKET (CAN-BUS 250kbps) +Data Sent: +Trail Crash Packet (CAN-Bus 250kbps)
@@ -538,10 +538,10 @@ export default function FeaturesPage() {
-04 // OBD-II & ECU ENGINE DIAGNOSTICS +04 // OBD-II & ECU Engine Diagnostics
-

+

Direct CAN-bus & ECU Health Stream

@@ -556,13 +556,13 @@ export default function FeaturesPage() {
thermostat -ENGINE COOLANT +Engine Coolant
-NOMINAL +Nominal
88° -CELSIUS +Celsius

ECU threshold alerts warn of radiator heat-soak before cylinder head distortion occurs. @@ -573,9 +573,9 @@ export default function FeaturesPage() {

oil_barrel -OIL HYDRAULIC PSI +Oil Hydraulic PSI
-HIGH STABILITY +High Stability
62.4 @@ -590,13 +590,13 @@ export default function FeaturesPage() {
settings_input_component -GEARBOX STATE +Gearbox State
-QUICKSHIFTER ACTIVE +Quickshifter Active
4 -GEAR // 11,400 RPM +Gear // 11,400 RPM

Monitors shift cut-off duration (ms), false neutral occurrences, and clutch slip ratios. @@ -607,13 +607,13 @@ export default function FeaturesPage() {

speed -CRANK RPM PEAK +Crank RPM Peak
-REDLINE 14,500 +Redline 14,500
13,850 -MAX RECORDED +Max Recorded

Full resolution engine rpm graph correlated with throttle opening percentage. @@ -624,13 +624,13 @@ export default function FeaturesPage() {

local_gas_station -FUEL INJECTION CONSUMPTION +Fuel Injection Consumption
-8.2 L / 100KM +8.2 L / 100km
3.8 -LAPS REMAINING FUEL +Laps Remaining Fuel

Sprint-race fuel management strategy to avoid carrying unnecessary ballast weight. @@ -641,13 +641,13 @@ export default function FeaturesPage() {

tire_repair -TPMS CARCASS TEMP +TPMS Carcass Temp
-TRACK GRIP OPTIMAL +Track Grip Optimal
82° / 78° -F / R REAR TIRE +F / R Rear Tire

Live cold/hot pressure inflation delta tracking to eliminate tire tearing and cold tears. @@ -658,22 +658,22 @@ export default function FeaturesPage() {

-OEM FACTORY HARNESS SUPPORT +OEM Factory Harness Support

Universal Plug & Play OBD-II / Euro5 Connectors

- ZERO ECU FLASHING REQUIRED + Zero ECU Flashing Required
-
YAMAHA
-
DUCATI
+
Yamaha
+
Ducati
BMW
-
KAWASAKI
+
Kawasaki
KTM
-
HONDA
-
APRILIA
-
TRIUMPH
+
Honda
+
Aprilia
+
Triumph
@@ -691,7 +691,7 @@ export default function FeaturesPage() {
cloud_done -SYNCHRONIZED (SESSION #142) +Synchronized (Session #142)
{/* Video Replay Frame Overlay */} @@ -702,8 +702,8 @@ export default function FeaturesPage() {
T: 01:24.810 -SPEED: 182 KM/H -LEAN: 51.4° L +Speed: 182 km/h +Lean: 51.4° L
- - + + +
-REGROUP POINT +Regroup Point Alice's Restaurant (14.8 mi)
@@ -137,8 +137,8 @@ export default function GroupRidesPage() { {/* Live Swarm Nodes Telemetry Column */}
-PACK TELEMETRY STREAM -6 ACTIVE +Pack Telemetry Stream +6 Active
{/* Rider 1: ApexLead */}
@@ -146,14 +146,14 @@ export default function GroupRidesPage() {
ApexLead -Pace Setter +Pace Setter
-LEAD +Lead
-
SPEED: 68 MPH
-
LEAN: 42°
-
THROTTLE: 78%
+
Speed: 68 MPH
+
Lean: 42°
+
Throttle: 78%
{/* Rider 2: Ghost99 */} @@ -162,14 +162,14 @@ export default function GroupRidesPage() {
Ghost99 -Wingman L +Wingman L
-+45m GAP ++45m Gap
-
SPEED: 67 MPH
-
LEAN: 40°
-
BRAKE: 0%
+
Speed: 67 MPH
+
Lean: 40°
+
Brake: 0%
{/* Rider 3: Sarah_RR (Warning: Tire Pressure) */} @@ -179,14 +179,14 @@ export default function GroupRidesPage() {
Sarah_RR -Wingman R +Wingman R
-+110m GAP ++110m Gap
tire_repair -ALERT: REAR TIRE 28 PSI (LOW) +Alert: Rear tire 28 PSI (low)
@@ -196,14 +196,14 @@ export default function GroupRidesPage() {
Dan_KTM -Mid Pack +Mid Pack
-+180m GAP ++180m Gap
-
SPEED: 63 MPH
-
LEAN: 28°
-
TEMP: 88°C
+
Speed: 63 MPH
+
Lean: 28°
+
Temp: 88°C
{/* Rider 5: Alex_Ducati (Yellow Flag: Hard Braking) */} @@ -213,13 +213,13 @@ export default function GroupRidesPage() {
Alex_Ducati -DECEL EVENT +Decel Event
-+320m GAP ++320m Gap
-HARD BRAKING (-1.2G) -ABS: ENGAGED +Hard Braking (-1.2G) +ABS: Engaged
{/* Rider 6: Sweep_Marshal */} @@ -228,14 +228,14 @@ export default function GroupRidesPage() {
Sweep_Marshal -Rear Guard +Rear Guard
-+450m GAP ++450m Gap
-
SPEED: 59 MPH
-
RADIO: OPEN
-
PACK LOCK: 100%
+
Speed: 59 MPH
+
Radio: Open
+
Pack Lock: 100%
@@ -245,12 +245,12 @@ export default function GroupRidesPage() { {/* Metric 1: Spacing Integrity */}
-SPACING INTEGRITY +Spacing Integrity verified
94% -SAFETY INDEX +Safety Index
@@ -259,38 +259,38 @@ export default function GroupRidesPage() { {/* Metric 2: Average Pack Speed */}
-AVERAGE PACK VELOCITY +Average Pack Velocity speed
64.2 MPH
-DELTA DRIFT: -1.8 MPH (TIGHTENING) +Delta Drift: -1.8 MPH (tightening)
{/* Metric 3: Atmospheric & Track Conditions */}
-SURFACE & CLIMATE +Surface & Climate thermostat
21°C -DRY ASPHALT +Dry Asphalt
-0% PRECIP • GRIP OPTIMAL +0% Precip • Grip Optimal
{/* Metric 4: Next Regroup Waypoint */}
-REGROUP POINT +Regroup Point pin_drop
14.8 -MILES +Miles
-ALICE'S RESTAURANT CHECKPOINT +Alice's Restaurant Checkpoint
@@ -298,8 +298,8 @@ export default function GroupRidesPage() { {/* Group Ride Features Grid */}
-COCKPIT ARCHITECTURE -

ENGINEERED FOR SQUADRON COHESION

+Cockpit Architecture +

Engineered for Squadron Cohesion

High-frequency mesh communications keep the entire formation synchronized through blind apexes, switchbacks, and offline mountain passes.

@@ -309,14 +309,14 @@ export default function GroupRidesPage() {
spatial_audio_off
-01 / PACELINE AUDIT -

Staggered Paceline Warnings

+01 / Paceline Audit +

Staggered Paceline Warnings

Spatial audio HUD chimes dynamically sound whenever rider follow distance or lane positioning falls beneath calculated emergency stopping thresholds.

-RESPONSE: <12ms +Response: <12ms arrow_forward
@@ -326,14 +326,14 @@ export default function GroupRidesPage() {
fmd_bad
-02 / SWARM BROADCAST -

Crash & Separation Alerts

+02 / Swarm Broadcast +

Crash & Separation Alerts

Autonomous IMU-based impact detection pings the entire pack instantly. Visual yellow and red telemetry flags highlight precise coordinates for immediate regrouping.

-IMU SENSING: 6-AXIS +IMU Sensing: 6-Axis arrow_forward
@@ -343,14 +343,14 @@ export default function GroupRidesPage() {
hub
-03 / OFFLINE LINK -

Peer-to-Peer Mesh Sync

+03 / Offline Link +

Peer-to-Peer Mesh Sync

Never rely on cellular towers in deep canyons. High-throughput ad-hoc Bluetooth and Wi-Fi Direct protocols daisy-chain vital bike metrics from lead to sweep.

-ZERO-CELL READY +Zero-cell Ready arrow_forward
@@ -360,14 +360,14 @@ export default function GroupRidesPage() {
history_toggle_off
-04 / MISSION DEBRIEF -

Group Replay & Debrief

+04 / Mission Debrief +

Group Replay & Debrief

Relive the ride with synced multi-bike timelines. Compare apex apex entries, sector speeds, and individual lean angles on an interactive 3D map renderer.

-3D REPLAY ENGINE +3D Replay Engine arrow_forward
@@ -379,8 +379,8 @@ export default function GroupRidesPage() {
-HARDWARE COMPATIBILITY -

Sync directly with your helmet comms & bike dash

+Hardware Compatibility +

Sync directly with your helmet comms & bike dash

Integrates seamlessly with Cardo, Sena, Apple Watch, and OBD-II CAN bus links for live peg-to-peg data overlays.

@@ -388,11 +388,11 @@ export default function GroupRidesPage() {
headset_mic -CARDO / SENA HUD +Cardo / Sena HUD
watch -HAPTIC ALERTS +Haptic Alerts
@@ -402,8 +402,8 @@ export default function GroupRidesPage() {
-REGIONAL OPERATIONS -

Active & Upcoming Pack Runs

+Regional Operations +

Active & Upcoming Pack Runs

{/* Filter Toggles */}
@@ -420,8 +420,8 @@ export default function GroupRidesPage() {
-SORT BY: -START TIME ↓ +Sort by: +Start Time ↓
{/* Rides Cards Grid */} @@ -432,19 +432,19 @@ export default function GroupRidesPage() {
-
- SATURDAY • 06:30 AM PST +
+ Saturday • 06:30 AM PST
-
- TRACK PACE +
+ Track Pace
-SAN FRANCISCO, CA -82.4 MILES +San Francisco, CA +82.4 Miles
-

Bay Area Dawn Patrol

+

Bay Area Dawn Patrol

Fast sweepers from Woodside down to San Gregorio beach before morning traffic. Strict staggered formation enforced.

@@ -455,7 +455,7 @@ export default function GroupRidesPage() {
group -14/16 RIDERS +14/16 Riders
@@ -473,19 +473,19 @@ export default function GroupRidesPage() {
-
- SUNDAY • 07:15 AM PST +
+ Sunday • 07:15 AM PST
-
- BRISK PACE +
+ Brisk Pace
-MALIBU, CA -64.0 MILES +Malibu, CA +64.0 Miles
-

Malibu Canyons Fast Pace

+

Malibu Canyons Fast Pace

Tuna Canyon descent to Latigo Canyon technical curves. Regroup checkpoint planned at Rock Store overlook.

@@ -496,7 +496,7 @@ export default function GroupRidesPage() {
group -9/12 RIDERS +9/12 Riders
@@ -514,19 +514,19 @@ export default function GroupRidesPage() {
-
- FRI-SUN • OCT 12-14 +
+ Fri-Sun • Oct 12-14
-
- ENDURANCE / CHILL +
+ Endurance / Chill
-ROBBINSVILLE, NC -318 CURVES +Robbinsville, NC +318 Curves
-

Tail of the Dragon Run

+

Tail of the Dragon Run

11 miles, 318 technical curves. Telemetry debrief sessions every evening with custom apex analysis and gear logs.

@@ -537,7 +537,7 @@ export default function GroupRidesPage() {
group -FULL (24/24) +Full (24/24)
@@ -557,7 +557,7 @@ export default function GroupRidesPage() {
qr_code_scanner -

Join Pack Formation

+

Join Pack Formation

+ +
+
+redeem +Save 25% + Free OBD-II Dongle +
+
+ + +{/* Pricing Matrix (3 Core Software Tiers) */} +
+
+{/* Tier 1: Free Rider */} +
+
+
+Baseline Level +Tier 01 +
+

Free Rider

+

Instant heads-up instrumentation using native phone accelerometers.

+
+$0 +/ forever +
+{/* Features List */} +
    +
  • +check_circle +Smartphone IMU & GPS telemetry (1Hz) +
  • +
  • +check_circle +Live lean angle & speed HUD interface +
  • +
  • +check_circle +Max 3 recorded ride logs in history cache +
  • +
  • +check_circle +Standard community group rides +
  • +
  • +check_circle +Basic SOS manual emergency dispatch +
  • +
  • +cancel +CAN-bus throttle & brake pressure +
  • +
  • +cancel +400+ circuit lap timing sectors +
  • +
+
+ + Get Started Free + +
+{/* Tier 2: Pro Telemetry (Highlighted Hero) */} +
+{/* Top Cyber Radiant Stroke Accent */} +
+
+
+Recommended +
+ + + + +Apex Sync +
+
+

Pro Telemetry

+

Full multi-sensor acquisition engine with precision ECU CAN metrics.

+
+ + {billingCycle === 'annual' ? '$10' : '$14'} + +/ month +
+

+ {billingCycle === 'annual' + ? 'Billed annually ($120/yr) • Includes FREE OBD-II Link' + : 'Billed monthly ($14/mo) • OBD-II Link separate'} +

+{/* Sparkline Visual Component */} +
+
+Apex Sampling Trace +10Hz Multi-GNSS +
+ + + + +
+{/* Features List */} +
    +
  • +verified +10Hz Multi-GNSS satellite lock & 60fps high-refresh HUD +
  • +
  • +verified +Full 6-axis IMU gyro analytics (pitch, roll, yaw, lateral Gs) +
  • +
  • +verified +Unlimited cloud ride history & 3D sector replay +
  • +
  • +verified +Automatic crash impact beacon (<150ms trigger) +
  • +
  • +verified +Advanced Pack Radar with peer-to-peer mesh synchronization +
  • +
  • +electric_bolt +OBD-II & CAN-bus live data (RPM, throttle %, brake psi, gear) +
  • +
  • +flag +Track day mode with 400+ circuit lap timing & sector splits +
  • +
+
+ + Start 30-Day Free Trial + +
+{/* Tier 3: Club & Racing Team */} +
+
+
+Fleet & Competition +Multi-Rider +
+

Club & Racing Team

+

Telemetry command headquarters for racing teams, riding clubs, and marshals.

+
+ + {billingCycle === 'annual' ? '$40' : '$49'} + +/ month +
+

+ {billingCycle === 'annual' + ? 'Billed annually ($490/yr) • Up to 15 racers' + : 'Billed monthly ($49/mo) • Up to 15 racers'} +

+{/* Features List */} +
    +
  • +groups +Everything in Pro for up to 15 team riders +
  • +
  • +analytics +Central club marshal & paddock tactical dashboard +
  • +
  • +leaderboard +Private sector leaderboards & verified lap telemetry +
  • +
  • +download +Export FIM-compliant CSV/KML data for crew chiefs +
  • +
  • +podium +Live pit-lane spectator broadcast link & delta map +
  • +
  • +support_agent +Dedicated priority engineering telemetry support +
  • +
+
+ + Open Founder Portal + +
+
+
+{/* Telemetry Precision Metrics Matrix */} +
+
+
+
+Architectural Comparison +

Full Feature Telemetry Breakdown

+
+
+ SPEC STANDARD: ISO-11898 CAN 2.0B +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Telemetry CapabilityFree RiderPro TelemetryClub & Team
Sampling Frequency1 Hz (Phone GPS)10 Hz Multi-GNSS10 Hz + 100Hz IMU
Lean Angle & G-Force TraceRealtime HUD onlyStored per millisecondMulti-rider overlay compare
ECU Diagnostics & CAN DataNoneThrottle, RPM, Brake, GearRaw CAN logging & PID maps
Circuit Lap Timing & DeltasManual start/stop400+ GPS micro-sectorsTeam Live Pit Deltas
Ride History Cloud Storage3 sessionsUnlimited full resolutionUnlimited + Paddock Archive
Crash Detection & Mesh SOSManual SOS<150ms Automatic TriggerFleetwide Marshall Auto-Dispatch
+
+
+
+{/* Hardware Add-ons Section */} +
+
+Hardware Ecosystem +

Engineered Motorcycle Hardware

+

+ Industrial grade modular hardware plugs directly into modern motorcycle diagnostic links. Instant sync with the MotorSignal app over high-bandwidth Bluetooth 5.3. +

+
+
+{/* Hardware Item 1: OBD-II / CAN Dongle */} +
+
+ +IP68 Waterproof +
+
+
+
+

MotorSignal Link Dongle

+$99 +
+

Free with annual Pro subscription

+

+ Plugs straight into your bike's Euro4/Euro5 diagnostic port. Reads real-time brake line hydraulic pressure, lean-angle-sensitive traction control, and exact throttle opening. +

+
+
+bluetooth +BT 5.3 Low Energy +
+
+wifi +Wi-Fi Direct 60fps +
+
+cable +Yamaha / KTM / Ducati +
+
+bolt +<8mA Draw Sleep +
+
+
+ +View Specs & Harness Fits +arrow_forward + +
+
+{/* Hardware Item 2: Fork IMU Module */} +
+
+ +100Hz High Density +
+
+
+
+

6-Axis Fork IMU Module

+$149 +
+

Optional track precision add-on

+

+ Dedicated fork clamp sensor providing MotoGP-grade chassis pitch dynamics, suspension travel velocity, and high-frequency chatter detection. +

+
+
+sensors +100Hz Gyro / Accel +
+
+precision_manufacturing +CNC 6061 Billet Clamp +
+
+straighten +41mm to 55mm Fork Stems +
+
+sync +Zero-lag CAN Bridging +
+
+
+ +Order Fork Module +arrow_forward + +
+
+
+
+{/* Interactive FAQ Accordion */} +
+
+
+Knowledge Base +

Frequently Asked Questions

+
+
+{/* Q1 */} +
+ +
+ No. You can start immediately using solely your smartphone. Modern smartphones contain high-grade 6-axis accelerometers, gyroscopes, and GNSS modules capable of delivering 80% of our telemetry features including live lean angle, acceleration curves, and lap timing. The optional OBD-II hardware module unlocks deeper bike-specific metrics: engine RPM, gear position, throttle position percentage, traction control engagement, and hydraulic brake pressure. +
+
+{/* Q2 */} +
+ +
+ Yes. If you are riding in remote mountain passes without cellular coverage, the system initiates a dual fallback: it records high-precision blackbox sensor data locally, and simultaneously emits a high-priority emergency beacon via peer-to-peer Wi-Fi and Bluetooth mesh to nearby riders in your group pack. As soon as any pack member's device touches cellular or satellite connectivity, the incident coordinates are dispatched immediately. +
+
+{/* Q3 */} +
+ +
+ You can cancel your subscription at any time with a single click in your Account settings. There are no cancellation penalties. Your ride telemetry and track logs are yours forever: you can export all your past session data in raw CSV, GPX, or FIM-standard KML formats at any point, even on the free tier. +
+
+{/* Q4 */} +
+ +
+ The phone sensor suite works on any vehicle with two wheels. For our OBD-II CAN Link dongle, all Euro4 and Euro5 compliant fuel-injected motorcycles from 2017 to present are supported out-of-the-box (including Yamaha, Ducati, BMW Motorrad, KTM, Kawasaki, Honda, Aprilia, Triumph, and Suzuki). We include custom pin adapter harnesses with every dongle order. +
+
+
+
+
+{/* Tactical Final Banner CTA */} +
+
+
+
+Instant Telemetry Link +

+ Ready to analyze your apex angles and lap splits? +

+

+ Download the app, mount your phone, and be track-ready in under 60 seconds. No credit card required for the free rider tier. +

+ +
+
+
+ + + + + ); +} diff --git a/website/app/register/page.tsx b/website/app/register/page.tsx index 8883d8a..c1fb638 100644 --- a/website/app/register/page.tsx +++ b/website/app/register/page.tsx @@ -1,4 +1,38 @@ import Link from 'next/link'; import { AuthForm } from '../../components/auth-form'; -export default function RegisterPage() { return
Rider account

Bring your data with you.

Create a secure MotorSignal account for your rides, devices, and future web portal.

Already have an account? Sign in

; } +export default function RegisterPage() { + return ( +
+ {/* Background ambient glow */} +
+ +
+
+
+ + Cloud Telemetry +
+

+ Bring your data with you. +

+

+ Create a secure MotorSignal account for your rides, garage, and cloud telemetry. +

+
+ +
+
+ + +
+ Already have an account?{' '} + + Sign in + +
+
+
+
+ ); +} diff --git a/website/components/app-cloud-data.tsx b/website/components/app-cloud-data.tsx new file mode 100644 index 0000000..95cdcbd --- /dev/null +++ b/website/components/app-cloud-data.tsx @@ -0,0 +1,1058 @@ +'use client'; + +import { + collection, + getDocs, + addDoc, + deleteDoc, + doc, + updateDoc, + writeBatch, +} from 'firebase/firestore'; +import { onAuthStateChanged, User } from 'firebase/auth'; +import Link from 'next/link'; +import React, { useEffect, useState, useRef } from 'react'; +import { firebaseAuth, firestore } from '../lib/firebase-client'; + +export type Motorcycle = { + id: string; + nickname: string; + makeName: string; + modelName: string; + variantName: string; + year: number; + description: string; + isDefault: boolean; + imageUrl?: string; + vin?: string; +}; + +export type Ride = { + id: string; + startedAt: Date | null; + endedAt: Date | null; + distanceMiles: number; + topSpeedMph: number; + averageSpeed: number; + maxLeanLeft: number; + maxLeanRight: number; + maxAccelG: number; + maxBrakingG: number; + movingTime: number; // in seconds + elevationGain: number; + qualityScore: number; + motorcycle: string; +}; + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function number(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function formatDuration(seconds: number): string { + if (!seconds || seconds <= 0) return '0m'; + const mins = Math.floor(seconds / 60); + const hrs = Math.floor(mins / 60); + const remainingMins = mins % 60; + if (hrs > 0) { + return `${hrs}h ${remainingMins}m`; + } + return `${mins}m`; +} + +// Sleek high-tech stock sportbike vector illustration +function StockMotorcycleGraphic({ className = 'w-full h-40' }: { className?: string }) { + return ( +
+ {/* Background technical grid */} +
+ + + {/* Front Wheel */} + + + + {/* Rear Wheel */} + + + + {/* Swingarm */} + + {/* Frame & Engine Block */} + + {/* Exhaust Pipe */} + + + {/* Front Fork */} + + + {/* Fuel Tank & Tail */} + + {/* Aerodynamic Fairing & Windscreen */} + + + {/* Rider Seat */} + + {/* Gradients */} + + + + + + + + + + + Stock Spec Render + +
+ ); +} + +interface AppCloudDataProps { + syncTrigger?: number; + onSyncStateChange?: (syncing: boolean) => void; + onMetricsLoaded?: (metrics: { totalMiles: number; rideCount: number; peakLean: number }) => void; +} + +export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoaded }: AppCloudDataProps) { + const [motorcycles, setMotorcycles] = useState([]); + const [rides, setRides] = useState([]); + const [state, setState] = useState('Loading telemetry cloud data...'); + const [user, setUser] = useState(null); + + // Modal / Add Motorcycle Form State + const [showAddModal, setShowAddModal] = useState(false); + const [newYear, setNewYear] = useState(new Date().getFullYear().toString()); + const [newMake, setNewMake] = useState('Yamaha'); + const [newModel, setNewModel] = useState(''); + const [newVariant, setNewVariant] = useState('Standard'); + const [newNickname, setNewNickname] = useState(''); + const [newVin, setNewVin] = useState(''); + const [newIsDefault, setNewIsDefault] = useState(false); + const [newImagePreview, setNewImagePreview] = useState(null); + const [formBusy, setFormBusy] = useState(false); + const [formError, setFormError] = useState(''); + + // Image Upload Target State + const [uploadingMotorcycleId, setUploadingMotorcycleId] = useState(null); + const fileInputRef = useRef(null); + + // Expandable Ride Detail state + const [expandedRideId, setExpandedRideId] = useState(null); + + // Auto-resolved Stock Photography Cache (from /api/motorcycle-image) + const [stockImages, setStockImages] = useState< + Record + >({}); + + // Resolve stock images for bikes that don't have custom uploaded images + useEffect(() => { + motorcycles.forEach((bike) => { + if (bike.imageUrl) return; + const key = `${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase(); + if (key in stockImages || bike.id in stockImages) return; + + fetch( + `/api/motorcycle-image?make=${encodeURIComponent(bike.makeName)}&model=${encodeURIComponent( + bike.modelName + )}&year=${bike.year || ''}` + ) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const val = data?.imageUrl + ? { + imageUrl: data.imageUrl as string, + attribution: data.attribution as string, + caption: data.caption as string, + } + : null; + setStockImages((prev) => ({ + ...prev, + [bike.id]: val, + [key]: val, + })); + }) + .catch(() => { + setStockImages((prev) => ({ + ...prev, + [bike.id]: null, + [key]: null, + })); + }); + }); + }, [motorcycles, stockImages]); + + async function loadData(currentUser: User | null) { + if (!currentUser) { + setMotorcycles([]); + setRides([]); + setState('Sign in or connect your Google account to view your live telemetry.'); + if (onMetricsLoaded) onMetricsLoaded({ totalMiles: 0, rideCount: 0, peakLean: 0 }); + return; + } + + if (onSyncStateChange) onSyncStateChange(true); + try { + const [motorcycleSnapshot, rideSnapshot] = await Promise.all([ + 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)); + + let totalMiles = 0; + let peakLean = 0; + + 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); + + 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(' '), + }; + }).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0)); + + setMotorcycles(loadedMotorcycles); + setRides(loadedRides); + setState(''); + + if (onMetricsLoaded) { + onMetricsLoaded({ + totalMiles: Math.round(totalMiles), + rideCount: loadedRides.length, + peakLean: Number(peakLean.toFixed(1)), + }); + } + } 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.'); + } finally { + if (onSyncStateChange) onSyncStateChange(false); + } + } + + useEffect(() => { + const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => { + setUser(currentUser); + void loadData(currentUser); + }); + return () => unsubscribe(); + }, []); + + // React to parent Quick Sync trigger + useEffect(() => { + if (syncTrigger > 0 && user) { + void loadData(user); + } + }, [syncTrigger]); + + // Handle image upload compression + async function handleImageFileSelected(event: React.ChangeEvent, motorcycleId?: string) { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = async (e) => { + const img = new Image(); + img.onload = async () => { + const canvas = document.createElement('canvas'); + const MAX_WIDTH = 960; + const MAX_HEIGHT = 640; + let width = img.width; + let height = img.height; + + if (width > height) { + if (width > MAX_WIDTH) { + height *= MAX_WIDTH / width; + width = MAX_WIDTH; + } + } else { + if (height > MAX_HEIGHT) { + width *= MAX_HEIGHT / height; + height = MAX_HEIGHT; + } + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0, width, height); + const dataUrl = canvas.toDataURL('image/jpeg', 0.84); + + if (motorcycleId && user) { + // Direct update on existing motorcycle + try { + await updateDoc(doc(firestore, 'users', user.uid, 'motorcycles', motorcycleId), { + imageUrl: dataUrl, + updatedAt: new Date().toISOString(), + }); + setMotorcycles((prev) => + prev.map((m) => (m.id === motorcycleId ? { ...m, imageUrl: dataUrl } : m)) + ); + } catch (err) { + console.error('Failed to update motorcycle image:', err); + alert('Failed to save image. Please check permissions.'); + } + } else { + // Staged for new motorcycle modal + setNewImagePreview(dataUrl); + } + }; + img.src = e.target?.result as string; + }; + reader.readAsDataURL(file); + // Reset file input value + event.target.value = ''; + } + + // Handle Add Motorcycle submission + async function handleAddMotorcycle(event: React.FormEvent) { + event.preventDefault(); + if (!user) { + setFormError('Please sign in to add motorcycles.'); + return; + } + if (!newMake.trim() || !newModel.trim()) { + setFormError('Make and Model are required.'); + return; + } + + setFormBusy(true); + setFormError(''); + + try { + const yearInt = parseInt(newYear, 10) || new Date().getFullYear(); + const generatedNickname = newNickname.trim() || `${yearInt} ${newMake} ${newModel}`.trim(); + const shouldBeDefault = newIsDefault || motorcycles.length === 0; + + if (shouldBeDefault && motorcycles.length > 0) { + const batch = writeBatch(firestore); + motorcycles.forEach((m) => { + batch.update(doc(firestore, 'users', user.uid, 'motorcycles', m.id), { + isDefault: false, + }); + }); + await batch.commit(); + } + + const docRef = await addDoc(collection(firestore, 'users', user.uid, 'motorcycles'), { + year: yearInt, + makeName: newMake.trim(), + modelName: newModel.trim(), + variantName: newVariant.trim(), + nickname: generatedNickname, + vin: newVin.trim() || null, + isDefault: shouldBeDefault, + imageUrl: newImagePreview || null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const newBike: Motorcycle = { + id: docRef.id, + year: yearInt, + makeName: newMake.trim(), + modelName: newModel.trim(), + variantName: newVariant.trim(), + nickname: generatedNickname, + description: `${yearInt} ${newMake} ${newModel} ${newVariant}`.trim(), + isDefault: shouldBeDefault, + imageUrl: newImagePreview || undefined, + vin: newVin.trim() || undefined, + }; + + setMotorcycles((prev) => [ + newBike, + ...prev.map((m) => (shouldBeDefault ? { ...m, isDefault: false } : m)), + ]); + + // Reset form + setShowAddModal(false); + setNewModel(''); + setNewNickname(''); + setNewVin(''); + setNewImagePreview(null); + } catch (err) { + console.error('Error adding motorcycle:', err); + setFormError(err instanceof Error ? err.message : 'Unable to add motorcycle. Please retry.'); + } finally { + setFormBusy(false); + } + } + + // Handle Delete Motorcycle + async function handleDeleteMotorcycle(motorcycle: Motorcycle) { + if (!user) return; + const confirmDelete = window.confirm(`Remove ${motorcycle.nickname} from your garage?`); + if (!confirmDelete) return; + + try { + await deleteDoc(doc(firestore, 'users', user.uid, 'motorcycles', motorcycle.id)); + setMotorcycles((prev) => prev.filter((m) => m.id !== motorcycle.id)); + } catch (err) { + console.error('Error deleting motorcycle:', err); + alert('Unable to delete motorcycle. Please check connection and permissions.'); + } + } + + // Handle Make Primary + async function handleMakePrimary(motorcycleId: string) { + if (!user) return; + try { + const batch = writeBatch(firestore); + motorcycles.forEach((m) => { + batch.update(doc(firestore, 'users', user.uid, 'motorcycles', m.id), { + isDefault: m.id === motorcycleId, + }); + }); + await batch.commit(); + setMotorcycles((prev) => + prev + .map((m) => ({ ...m, isDefault: m.id === motorcycleId })) + .sort((a, b) => Number(b.isDefault) - Number(a.isDefault)) + ); + } catch (err) { + console.error('Error setting primary motorcycle:', err); + } + } + + // Remove custom photo to restore stock graphic + async function handleRemovePhoto(motorcycleId: string) { + if (!user) return; + try { + await updateDoc(doc(firestore, 'users', user.uid, 'motorcycles', motorcycleId), { + imageUrl: null, + updatedAt: new Date().toISOString(), + }); + setMotorcycles((prev) => + prev.map((m) => (m.id === motorcycleId ? { ...m, imageUrl: undefined } : m)) + ); + } catch (err) { + console.error('Error removing photo:', err); + } + } + + return ( +
+ {/* Hidden file input for photo uploads */} + void handleImageFileSelected(e, uploadingMotorcycleId ?? undefined)} + /> + + {/* ========================================================================= */} + {/* SECTION 1: VEHICLE GARAGE (Blue & Purple Areas) */} + {/* ========================================================================= */} +
+
+
+
+
+

+ Vehicle Garage & Telemetry Hardware +

+

+ Synced bikes, ECU links, and motorcycle profiles +

+
+
+ +
+ +
+
+ + {/* Motorcycle Grid */} + {motorcycles.length > 0 ? ( +
+ {motorcycles.map((bike) => ( +
+ {/* Accent top gradient */} +
+ + {/* Top Badges & Actions */} +
+
+ {bike.isDefault ? ( + + + Default Primary + + ) : ( + + )} +
+ + {/* Delete Motorcycle Action */} + +
+ + {/* Motorcycle Image Area (Purple Area - Stock Photo or Custom) */} +
+ {bike.imageUrl ? ( +
+ {bike.nickname} +
+ + + CUSTOM RIDER PHOTO + +
+ ) : (stockImages[bike.id] || stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])?.imageUrl ? ( +
+ {bike.nickname} +
+ + + STOCK SPEC PHOTO + + {((stockImages[bike.id] || + stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])! + .attribution) && ( + + { + (stockImages[bike.id] || + stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])! + .attribution + } + + )} +
+ ) : ( + + )} + + {/* Image Action Overlay Button */} +
+ {bike.imageUrl && ( + + )} + +
+
+ + {/* Bike Title & Specs */} +
+ + {[bike.year || '', bike.makeName, bike.variantName || 'Standard Spec'].filter(Boolean).join(' • ')} + +

+ {bike.nickname} +

+ {bike.vin && ( +

VIN: {bike.vin}

+ )} +
+ + {/* Telemetry Hardware Link Grid */} +
+
+ Telemetry Link + + + CAN Active + +
+
+ IMU Status + 6-Axis Live +
+
+ Cloud Sync + 100% Synced +
+
+
+ ))} +
+ ) : ( +
+ +
+

No Motorcycles in Garage

+

+ Add your bike to bind CAN-bus telemetry, IMU orientation, and lean angles. +

+
+ +
+ )} +
+ + {/* ========================================================================= */} + {/* SECTION 2: RECENT RIDES TELEMETRY FEED (White Area) */} + {/* ========================================================================= */} +
+
+
+
+
+

+ Recent Telemetry Sessions +

+

+ High-fidelity 20Hz GNSS, lean angles, and speed telemetry logs +

+
+
+ + {rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded + +
+ + {rides.length > 0 ? ( +
+ {rides.map((ride) => { + const isExpanded = expandedRideId === ride.id; + const dateStr = ride.startedAt + ? ride.startedAt.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + : 'Session Log'; + const timeStr = ride.startedAt + ? ride.startedAt.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }) + : ''; + + return ( +
+
+ {/* Ride Header Info */} +
+
+ {dateStr} + {timeStr && • {timeStr}} +
+
+ + {ride.motorcycle || 'Motorcycle'} + + + + {ride.qualityScore}% Sync + +
+
+ + {/* Metric Badges Grid */} +
+ {/* Distance */} +
+ Distance + + {ride.distanceMiles.toFixed(2)}{' '} + mi + +
+ + {/* Top Speed */} +
+ Top Speed + + {ride.topSpeedMph.toFixed(1)}{' '} + mph + +
+ + {/* Peak Lean Angle */} +
+ Max Lean (L / R) + + {ride.maxLeanLeft.toFixed(0)}° + / + {ride.maxLeanRight.toFixed(0)}° + +
+ + {/* Duration */} +
+ Duration + + {ride.movingTime > 0 ? formatDuration(ride.movingTime) : '< 1m'} + +
+
+ + {/* Details Toggle */} +
+ +
+
+ + {/* Expanded Telemetry Drawer */} + {isExpanded && ( +
+
+ AVG SPEED + + {ride.averageSpeed > 0 ? `${ride.averageSpeed.toFixed(1)} mph` : 'N/A'} + +
+
+ PEAK ACCEL + + {ride.maxAccelG > 0 ? `+${ride.maxAccelG.toFixed(2)} G` : '+0.00 G'} + +
+
+ PEAK DECEL + + {ride.maxBrakingG > 0 ? `-${ride.maxBrakingG.toFixed(2)} G` : '-0.00 G'} + +
+
+ ELEVATION GAIN + + {ride.elevationGain > 0 ? `${Math.round(ride.elevationGain)} ft` : '0 ft'} + +
+
+ )} +
+ ); + })} +
+ ) : ( +
+
+ + + +
+

No Telemetry Rides Synced Yet

+

+ Record a ride using the MotorSignal mobile app. Rides are automatically uploaded to Cloudflare R2 and Firestore upon completion. +

+
+ )} +
+ + {/* ========================================================================= */} + {/* MODAL: ADD MOTORCYCLE FORM (Blue Area) */} + {/* ========================================================================= */} + {showAddModal && ( +
+
+
+
+

Add Motorcycle

+

Register a bike in your cloud garage

+
+ +
+ +
+
+
+ + setNewYear(e.target.value)} + className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan" + /> +
+ +
+ + setNewMake(e.target.value)} + className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan" + /> +
+
+ +
+
+ + setNewModel(e.target.value)} + className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan" + /> +
+ +
+ + setNewVariant(e.target.value)} + className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan" + /> +
+
+ +
+ + setNewNickname(e.target.value)} + className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan" + /> +
+ +
+ + setNewVin(e.target.value)} + className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan" + /> +
+ + {/* Photo Upload in Modal */} +
+
+ + Auto-pulls stock photo if left empty +
+ {newImagePreview ? ( +
+ Preview + +
+ ) : ( + + )} +
+ +
+ setNewIsDefault(e.target.checked)} + className="rounded bg-surface-container-low border-outline-variant/40 text-brand-cyan focus:ring-brand-cyan" + /> + +
+ + {formError && ( +

+ {formError} +

+ )} + +
+ + +
+
+
+
+ )} +
+ ); +} + diff --git a/website/components/auth-form.tsx b/website/components/auth-form.tsx index cff5b7b..18a34fe 100644 --- a/website/components/auth-form.tsx +++ b/website/components/auth-form.tsx @@ -1,6 +1,8 @@ 'use client'; import { FormEvent, useState } from 'react'; +import { signInWithPopup } from 'firebase/auth'; +import { firebaseAuth, googleProvider } from '../lib/firebase-client'; type AuthMode = 'login' | 'register'; @@ -10,6 +12,42 @@ export function AuthForm({ mode }: { mode: AuthMode }) { const [deviceName, setDeviceName] = useState('Web browser'); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); + const [googleBusy, setGoogleBusy] = useState(false); + + async function handleGoogleSignIn() { + setGoogleBusy(true); + setMessage(''); + try { + const credential = await signInWithPopup(firebaseAuth, googleProvider); + const idToken = await credential.user.getIdToken(); + // Exchange firebase idToken with backend to set server session & ops cookies + try { + 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 destination = new URLSearchParams(window.location.search).get('returnTo') || '/account'; + window.location.assign(destination); + } catch (error) { + console.error('Google Sign-In Error:', error); + setMessage(error instanceof Error ? error.message : 'Google sign-in failed. Please try again.'); + } finally { + setGoogleBusy(false); + } + } async function submit(event: FormEvent) { event.preventDefault(); @@ -23,7 +61,8 @@ export function AuthForm({ mode }: { mode: AuthMode }) { }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.detail ?? 'Authentication failed.'); - window.location.assign('/account'); + const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account'; + window.location.assign(destination); } catch (error) { setMessage(error instanceof Error ? error.message : 'Authentication failed.'); } finally { @@ -32,12 +71,116 @@ export function AuthForm({ mode }: { mode: AuthMode }) { } return ( -
- - - - {message &&

{message}

} - -
+
+ {/* Google Sign-In / Registration Button */} + + + {/* Divider */} +
+
+ + or continue with email + +
+
+ + {/* Email / Password Form */} +
+
+ + setEmail(e.target.value)} + className="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" + /> +
+ +
+ + setPassword(e.target.value)} + className="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" + /> +
+ + {mode === 'register' && ( +
+ + setDeviceName(e.target.value)} + className="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" + /> +
+ )} + + {message && ( +
+ {message} +
+ )} + + +
+
); } diff --git a/website/components/firebase-link.tsx b/website/components/firebase-link.tsx new file mode 100644 index 0000000..408c4bb --- /dev/null +++ b/website/components/firebase-link.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { signInWithPopup } from 'firebase/auth'; +import { useState } from 'react'; +import { firebaseAuth, googleProvider } from '../lib/firebase-client'; + +export function FirebaseLink({ linked }: { linked: boolean }) { + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(linked ? 'Google app account connected.' : ''); + async function connect() { + setBusy(true); + setMessage(''); + try { + const credential = await signInWithPopup(firebaseAuth, googleProvider); + const response = await fetch('/api/account/firebase-link', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id_token: await credential.user.getIdToken() }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.detail ?? 'Unable to connect Google account.'); + setMessage('Google app account connected.'); + window.location.reload(); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Unable to connect Google account.'); + } finally { setBusy(false); } + } + return

{linked ? 'Your website account is linked to MotorSignal app data.' : 'Connect the Google account used in the MotorSignal app.'}

{!linked && }{message &&

{message}

}
; +} diff --git a/website/components/landing/HeroSection.tsx b/website/components/landing/HeroSection.tsx index 645a491..01172b5 100644 --- a/website/components/landing/HeroSection.tsx +++ b/website/components/landing/HeroSection.tsx @@ -11,12 +11,12 @@ export default function HeroSection() { {/* Hero Copy */}
- + - - ⚡ NEXT-GEN TELEMETRY ENGINE 3.0 • REAL-TIME CAN-BUS & GPS + + ⚡ Next-Gen Telemetry Engine 3.0 • Real-Time CAN-Bus & GPS
@@ -82,33 +82,33 @@ export default function HeroSection() { {/* Top Badge: Apex Vector Locked */}
- APEX VECTOR LOCKED - | CAN-BUS 100Hz + Apex Vector Locked + | CAN-Bus 100Hz
{/* Floating HUD 1: Lean Angle */}
- LEAN ANGLE - LIVE + Lean Angle + Live
54° - LEFT + Left
0° - PEAK 58° + Peak 58°
{/* Floating HUD 2: Velocity & G-Force */}
- VELOCITY & G-FORCE + Velocity & G-Force speed
@@ -116,7 +116,7 @@ export default function HeroSection() { MPH
- LATERAL + Lateral +1.82 G
@@ -126,17 +126,17 @@ export default function HeroSection() {
satellite_alt - GPS MULTI-BAND + GPS Multi-Band
- 10Hz LOCK + 10Hz Lock
- SYNC LATENCY + Sync Latency 12ms
- SAT IN VIEW - 24 SATS + Sat in View + 24 Sats
diff --git a/website/components/recovery-form.tsx b/website/components/recovery-form.tsx index 12d305c..a186042 100644 --- a/website/components/recovery-form.tsx +++ b/website/components/recovery-form.tsx @@ -2,9 +2,9 @@ import { FormEvent, useState } from 'react'; -export function RecoveryForm() { +export function RecoveryForm({ initialToken = '' }: { initialToken?: string }) { const [email, setEmail] = useState(''); - const [token, setToken] = useState(''); + const [token, setToken] = useState(initialToken); const [password, setPassword] = useState(''); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); diff --git a/website/components/session-manager.tsx b/website/components/session-manager.tsx new file mode 100644 index 0000000..8d9d19f --- /dev/null +++ b/website/components/session-manager.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Session = { id: string; device_name: string | null; created_at: string; expires_at: string; current: boolean }; + +export function SessionManager() { + const [sessions, setSessions] = useState([]); + const [message, setMessage] = useState(''); + + async function load() { + const response = await fetch('/api/account/sessions', { cache: 'no-store' }); + if (response.ok) setSessions(await response.json()); + } + + useEffect(() => { void load(); }, []); + + async function revoke(id: string) { + const response = await fetch(`/api/account/sessions/${id}`, { method: 'DELETE' }); + if (response.ok) { setMessage('Session revoked.'); await load(); } + } + + async function logoutAll() { + const response = await fetch('/api/account/sessions', { method: 'POST' }); + if (response.ok) { setMessage('All sessions revoked.'); await load(); } + } + + return

Sessions

{sessions.length ? sessions.map((session) =>
{session.device_name ?? 'Unnamed device'} {session.current && '(this device)'}

Created {new Date(session.created_at).toLocaleString()}

{!session.current && }
) :

No active sessions.

}{message &&

{message}

}
; +} diff --git a/website/components/sign-out-button.tsx b/website/components/sign-out-button.tsx new file mode 100644 index 0000000..348ff81 --- /dev/null +++ b/website/components/sign-out-button.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useState } from 'react'; + +export function SignOutButton() { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + async function signOut() { + setBusy(true); + setError(''); + try { + const response = await fetch('/api/auth/logout', { method: 'POST' }); + if (!response.ok) throw new Error('Sign out failed. Please retry.'); + window.location.assign('/login'); + } catch { + setError('Sign out failed. Please retry.'); + setBusy(false); + } + } + return
{error &&

{error}

}
; +} diff --git a/website/components/site-footer.tsx b/website/components/site-footer.tsx index 24293be..feb5e88 100644 --- a/website/components/site-footer.tsx +++ b/website/components/site-footer.tsx @@ -4,7 +4,8 @@ import { usePathname } from 'next/navigation'; export function SiteFooter() { const pathname = usePathname(); - if (pathname?.includes('/manage') || pathname?.startsWith('/admin') || pathname?.startsWith('/ops')) return null; + const isClubManageInterior = pathname !== '/clubs/manage' && Boolean(pathname?.startsWith('/clubs/') && pathname?.includes('/manage')); + if (isClubManageInterior || pathname?.startsWith('/admin') || pathname?.startsWith('/ops')) return null; return (