794 lines
36 KiB
TypeScript
794 lines
36 KiB
TypeScript
'use client';
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { onAuthStateChanged, multiFactor, User } from 'firebase/auth';
|
||
import { firebaseAuth } from '../../lib/firebase-client';
|
||
import { AppCloudData, type CloudDataStatus } from '../../components/app-cloud-data';
|
||
import { FirebaseLink } from '../../components/firebase-link';
|
||
import { SignOutButton } from '../../components/sign-out-button';
|
||
|
||
type DeviceSession = {
|
||
id: string;
|
||
device_name: string | null;
|
||
created_at: string;
|
||
expires_at?: string;
|
||
current: boolean;
|
||
};
|
||
|
||
export default function AccountPage() {
|
||
const [user, setUser] = useState<User | null>(null);
|
||
const [authChecked, setAuthChecked] = useState(false);
|
||
const [syncTrigger, setSyncTrigger] = useState(0);
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [lastSynced, setLastSynced] = useState<string | null>(null);
|
||
const [syncError, setSyncError] = useState<string | null>(null);
|
||
// Truthful metrics & sync states:
|
||
// Distinguish 'idle', 'loading', 'unauthenticated', 'ready', 'permission-denied', 'unavailable', and 'error'.
|
||
// Never initialize unavailable information in a way that displays as verified zero metrics or 100% synced.
|
||
const [metricsStatus, setMetricsStatus] = useState<CloudDataStatus>('loading');
|
||
const [metrics, setMetrics] = useState<{ totalMiles: number; rideCount: number; peakLean: number } | null>(null);
|
||
|
||
// Active Device Sessions State
|
||
const [sessions, setSessions] = useState<DeviceSession[]>([]);
|
||
const [sessionMessage, setSessionMessage] = useState('');
|
||
const [visibleSessionCount, setVisibleSessionCount] = useState(10);
|
||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||
|
||
// Billing Management State
|
||
const [isBillingLoading, setIsBillingLoading] = useState(false);
|
||
const [billingNotice, setBillingNotice] = useState<{ text: string; type: 'error' | 'info' | 'success' } | null>(null);
|
||
|
||
useEffect(() => {
|
||
const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => {
|
||
setUser(currentUser);
|
||
setAuthChecked(true);
|
||
if (!currentUser) {
|
||
setMetricsStatus('unauthenticated');
|
||
setMetrics(null);
|
||
setSyncError(null);
|
||
} else {
|
||
setMetricsStatus('loading');
|
||
setMetrics(null);
|
||
}
|
||
});
|
||
return () => unsubscribe();
|
||
}, []);
|
||
|
||
// Truthful 2FA state: reflect the user's actual Firebase multi-factor enrollment.
|
||
// The web app has no MFA enrollment flow, so this is read-only; enrollment is done in
|
||
// the mobile app. Never claim 2FA is enabled unless a factor is actually enrolled.
|
||
const twoFactorEnrolled = (() => {
|
||
if (!user) return false;
|
||
try {
|
||
return multiFactor(user).enrolledFactors.length > 0;
|
||
} catch {
|
||
return false;
|
||
}
|
||
})();
|
||
|
||
async function loadSessions() {
|
||
setIsLoadingSessions(true);
|
||
try {
|
||
const response = await fetch('/api/account/sessions', { cache: 'no-store' });
|
||
if (response.ok) {
|
||
const data: DeviceSession[] = await response.json();
|
||
setSessions(Array.isArray(data) ? data : []);
|
||
} else {
|
||
setSessions([]);
|
||
}
|
||
} catch {
|
||
setSessions([]);
|
||
} finally {
|
||
setIsLoadingSessions(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void loadSessions();
|
||
}, []);
|
||
|
||
async function handleRevokeSession(id: string) {
|
||
try {
|
||
const response = await fetch(`/api/account/sessions/${id}`, { method: 'DELETE' });
|
||
if (response.ok) {
|
||
setSessionMessage('Session revoked.');
|
||
await loadSessions();
|
||
} else {
|
||
setSessionMessage('Failed to revoke session.');
|
||
}
|
||
} catch {
|
||
setSessionMessage('Network error revoking session.');
|
||
}
|
||
}
|
||
|
||
async function handleRevokeAllSessions() {
|
||
try {
|
||
const response = await fetch('/api/account/sessions', { method: 'POST' });
|
||
if (response.ok) {
|
||
setSessionMessage('All sessions revoked.');
|
||
await loadSessions();
|
||
} else {
|
||
setSessionMessage('Failed to revoke all sessions.');
|
||
}
|
||
} catch {
|
||
setSessionMessage('Network error revoking all sessions.');
|
||
}
|
||
}
|
||
|
||
async function handleManagePlan() {
|
||
if (!user) {
|
||
setBillingNotice({
|
||
type: 'info',
|
||
text: 'Please sign in to manage your plan and billing invoices.',
|
||
});
|
||
return;
|
||
}
|
||
|
||
setIsBillingLoading(true);
|
||
setBillingNotice(null);
|
||
|
||
try {
|
||
const response = await fetch('/api/billing/portal', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data?.url) {
|
||
window.location.href = data.url;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (response.status === 404) {
|
||
setBillingNotice({
|
||
type: 'error',
|
||
text: 'Web billing portal is currently unavailable (endpoint /api/billing/portal is unconfigured). Subscriptions and invoices are managed through the mobile app.',
|
||
});
|
||
} else if (response.status === 401) {
|
||
setBillingNotice({
|
||
type: 'error',
|
||
text: 'Authentication required. Please sign in again to access the billing portal.',
|
||
});
|
||
} else {
|
||
const errorData = await response.json().catch(() => null);
|
||
setBillingNotice({
|
||
type: 'error',
|
||
text: errorData?.detail || errorData?.message || 'Unable to load billing portal at this time. Please manage subscriptions in the mobile app.',
|
||
});
|
||
}
|
||
} catch {
|
||
setBillingNotice({
|
||
type: 'error',
|
||
text: 'Web billing portal is unconfigured or unreachable. Subscriptions and invoices can be managed directly in the mobile app.',
|
||
});
|
||
} finally {
|
||
setIsBillingLoading(false);
|
||
}
|
||
}
|
||
|
||
function handleQuickSync() {
|
||
if (!user) return;
|
||
setIsSyncing(true);
|
||
setSyncError(null);
|
||
setMetricsStatus('loading');
|
||
setSyncTrigger((prev) => prev + 1);
|
||
}
|
||
|
||
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 (
|
||
<div className="flagship-page text-slate-200 bg-brand-bg min-h-screen pt-24 pb-20">
|
||
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
||
{/* BEGIN: HeroIdentitySection */}
|
||
<section className="glass-card rounded-2xl border border-brand-border p-6 md:p-8 relative overflow-hidden" data-purpose="rider-identity-banner">
|
||
{/* Top aesthetic accent line (Red Area Fixed) */}
|
||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-brand-cyan/80 via-blue-500/40 to-transparent"></div>
|
||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6 relative z-10">
|
||
{/* Rider Info */}
|
||
<div className="flex items-start md:items-center space-x-5">
|
||
{/* Rider Avatar / Helmet Node */}
|
||
<div className="relative shrink-0">
|
||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-brand-borderLight to-slate-900 border border-brand-cyan/40 p-1 flex items-center justify-center shadow-xl">
|
||
<span className="font-mono text-3xl font-extrabold text-brand-cyan tracking-wider">{initials}</span>
|
||
</div>
|
||
<span className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-slate-500 border-2 border-brand-bg flex items-center justify-center" title="Motorcycle hardware telemetry is not available">
|
||
<svg className="w-3 h-3 text-black fill-current" viewBox="0 0 20 20"><path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"></path></svg>
|
||
</span>
|
||
</div>
|
||
{/* Titles & Metadata */}
|
||
<div>
|
||
<div className="flex items-center space-x-2 text-xs font-mono text-brand-emerald tracking-normal mb-1">
|
||
<span className="inline-block w-2 h-2 rounded-full bg-brand-emerald animate-ping"></span>
|
||
<span>Rider Portal // Telemetry Cockpit</span>
|
||
</div>
|
||
<h1 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
|
||
{user ? `Welcome back, ${user.displayName || user.email?.split('@')[0] || 'Rider'}.` : 'Rider Account & Cloud Telemetry'}
|
||
</h1>
|
||
<div className="flex flex-wrap items-center gap-y-1 gap-x-3 mt-2 text-sm text-slate-400 font-mono">
|
||
<span className="text-slate-300 font-medium">{user?.email || 'Guest Session (Not Signed In)'}</span>
|
||
<span>•</span>
|
||
<span className="inline-flex items-center text-brand-cyan">
|
||
<svg className="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"></path></svg>
|
||
{user ? 'Pro Telemetry Tier' : 'Guest Tier'}
|
||
</span>
|
||
<span>•</span>
|
||
<span>Status: <span className="text-slate-200">{user ? 'Active Rider' : 'Signed Out'}</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* Quick Action Buttons (Green Area Fixed) */}
|
||
<div className="flex flex-wrap items-center gap-3 pt-2 lg:pt-0">
|
||
{user ? (
|
||
<>
|
||
<Link
|
||
href="/group-rides"
|
||
className="px-4 py-2 rounded-xl bg-gradient-to-r from-brand-cyan/20 to-blue-600/20 hover:from-brand-cyan/30 hover:to-blue-600/30 text-brand-cyan text-sm font-semibold border border-brand-cyan/40 hover:border-brand-cyan transition flex items-center space-x-2"
|
||
>
|
||
<span className="material-symbols-outlined text-[18px]">groups</span>
|
||
<span>Group Rides Hub</span>
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={handleQuickSync}
|
||
disabled={isSyncing}
|
||
className="px-4 py-2 rounded-xl bg-brand-cardAlt hover:bg-slate-800 text-slate-200 text-sm font-medium border border-brand-border hover:border-brand-cyan/60 transition flex items-center space-x-2 cursor-pointer disabled:opacity-60"
|
||
>
|
||
<svg
|
||
className={`w-4 h-4 ${isSyncing ? 'animate-spin text-brand-cyan' : 'text-slate-400'}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth="2"
|
||
></path>
|
||
</svg>
|
||
<span>
|
||
{isSyncing
|
||
? 'Syncing...'
|
||
: syncError
|
||
? 'Sync Failed (Retry)'
|
||
: lastSynced
|
||
? `Synced (${lastSynced})`
|
||
: 'Quick Sync'}
|
||
</span>
|
||
</button>
|
||
<div className="px-3 py-1.5 rounded-lg bg-brand-cardAlt border border-brand-border text-xs font-mono text-rose-400">
|
||
<SignOutButton />
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Link href="/login" className="px-4 py-2 rounded-lg bg-gradient-to-r from-brand-cyan to-blue-600 hover:opacity-95 text-slate-950 font-semibold text-sm transition">
|
||
Sign In
|
||
</Link>
|
||
<Link href="/register" className="px-4 py-2 rounded-lg bg-brand-cardAlt hover:bg-slate-800 text-slate-200 text-sm font-medium border border-brand-border">
|
||
Register
|
||
</Link>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{/* END: HeroIdentitySection */}
|
||
{/* BEGIN: TelemetryKpiGrid */}
|
||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-purpose="telemetry-overview-stats">
|
||
{/* Metric 1: Distance */}
|
||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-distance">
|
||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||
<span>Total Distance Logged</span>
|
||
{metricsStatus === 'loading' ? (
|
||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Loading…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign in to view</span>
|
||
) : metricsStatus === 'permission-denied' ? (
|
||
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
|
||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync unavailable</span>
|
||
) : (
|
||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Cloud Aggregate</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 flex items-baseline space-x-2">
|
||
{metricsStatus === 'ready' && metrics !== null ? (
|
||
<>
|
||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.totalMiles.toLocaleString()}</span>
|
||
<span className="text-sm font-mono text-slate-400">miles</span>
|
||
</>
|
||
) : metricsStatus === 'loading' ? (
|
||
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
|
||
) : (
|
||
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||
{metricsStatus === 'ready' ? (
|
||
<>
|
||
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
|
||
<span>All-time telemetry aggregate</span>
|
||
</>
|
||
) : metricsStatus === 'loading' ? (
|
||
<span>Retrieving telemetry aggregate…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span>Sign in to view telemetry aggregate</span>
|
||
) : metricsStatus === 'permission-denied' ? (
|
||
<span className="text-rose-400">Access to cloud telemetry restricted</span>
|
||
) : (
|
||
<span className="text-amber-400">Telemetry aggregate unavailable</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Metric 2: Sessions */}
|
||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-sessions">
|
||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||
<span>Recorded Sessions</span>
|
||
{metricsStatus === 'loading' ? (
|
||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Checking sync…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign-in required</span>
|
||
) : metricsStatus === 'permission-denied' ? (
|
||
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
|
||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync status unavailable</span>
|
||
) : metrics !== null && metrics.rideCount > 0 ? (
|
||
<span className="text-brand-emerald bg-brand-emerald/10 px-1.5 py-0.5 rounded text-[10px]">100% Cloud Synced</span>
|
||
) : (
|
||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Cloud Synced</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 flex items-baseline space-x-2">
|
||
{metricsStatus === 'ready' && metrics !== null ? (
|
||
<>
|
||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.rideCount}</span>
|
||
<span className="text-sm font-mono text-slate-400">rides</span>
|
||
</>
|
||
) : metricsStatus === 'loading' ? (
|
||
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
|
||
) : (
|
||
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||
{metricsStatus === 'ready' && metrics !== null ? (
|
||
metrics.rideCount > 0 ? (
|
||
<span>Canyons, Circuits & Commutes</span>
|
||
) : (
|
||
<span>No rides recorded yet</span>
|
||
)
|
||
) : metricsStatus === 'loading' ? (
|
||
<span>Checking cloud telemetry sessions…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span>Sign in to load synced rides</span>
|
||
) : metricsStatus === 'permission-denied' ? (
|
||
<span className="text-rose-400">Check Firestore permissions</span>
|
||
) : (
|
||
<span className="text-amber-400">Could not retrieve sessions from cloud</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Metric 3: Lean Angle Peak */}
|
||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-lean">
|
||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||
<span>Peak Lean Angle</span>
|
||
{metricsStatus === 'loading' ? (
|
||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Loading…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign in to view</span>
|
||
) : metricsStatus === 'permission-denied' ? (
|
||
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
|
||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync unavailable</span>
|
||
) : (
|
||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Lateral Roll</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 flex items-baseline space-x-2">
|
||
{metricsStatus === 'ready' && metrics !== null ? (
|
||
<>
|
||
<span className="text-3xl font-extrabold text-brand-cyan font-mono">
|
||
{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
|
||
</span>
|
||
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
||
</>
|
||
) : metricsStatus === 'loading' ? (
|
||
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
|
||
) : (
|
||
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? (
|
||
<span className="text-amber-400">6-Axis IMU Verified</span>
|
||
) : metricsStatus === 'loading' ? (
|
||
<span>Retrieving lean telemetry…</span>
|
||
) : metricsStatus === 'unauthenticated' ? (
|
||
<span>Sign in to view lean telemetry</span>
|
||
) : metricsStatus === 'permission-denied' ? (
|
||
<span className="text-rose-400">Lean telemetry restricted</span>
|
||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||
<span className="text-amber-400">Lean telemetry unavailable</span>
|
||
) : (
|
||
<span className="text-slate-500">No IMU roll data</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Metric 4: Hardware Link */}
|
||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200">
|
||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||
<span>Hardware Telemetry</span>
|
||
<span className="w-2.5 h-2.5 rounded-full bg-brand-emerald animate-pulse"></span>
|
||
</div>
|
||
<div className="mt-3 flex items-baseline space-x-2">
|
||
<span className="text-xl font-bold text-white font-mono">Not connected</span>
|
||
</div>
|
||
<div className="mt-2 text-xs text-brand-emerald font-mono flex items-center space-x-1.5">
|
||
<span>OBD-II / CAN telemetry unavailable</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{/* END: TelemetryKpiGrid */}
|
||
|
||
{/* Live App Cloud Data (Vehicle Garage, Telemetry Hardware & Recent Rides from Firestore) */}
|
||
<AppCloudData
|
||
syncTrigger={syncTrigger}
|
||
onSyncStateChange={(syncing) => {
|
||
setIsSyncing(syncing);
|
||
}}
|
||
onStatusChange={(status, _err) => {
|
||
setMetricsStatus(status);
|
||
if (status === 'ready') {
|
||
setSyncError(null);
|
||
setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||
} else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') {
|
||
setMetrics(null);
|
||
setSyncError('Sync failed');
|
||
} else if (status === 'unauthenticated') {
|
||
setMetrics(null);
|
||
setSyncError(null);
|
||
} else if (status === 'loading') {
|
||
setMetrics(null);
|
||
}
|
||
}}
|
||
onMetricsLoaded={(m) => {
|
||
setMetrics(m);
|
||
setMetricsStatus('ready');
|
||
}}
|
||
/>
|
||
{/* BEGIN: PlanAndClusterSection */}
|
||
<section className="grid grid-cols-1 lg:grid-cols-12 gap-6" data-purpose="subscription-and-sync-status">
|
||
{/* Left 7 cols: Plan & Hardware Dongle */}
|
||
<div className="lg:col-span-7 glass-card rounded-xl border border-brand-border p-6 flex flex-col justify-between" data-purpose="subscription-tier">
|
||
<div>
|
||
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-5">
|
||
<div>
|
||
<span className="text-xs font-mono tracking-normal text-brand-cyan">Subscription billing</span>
|
||
<h3 className="text-xl font-bold text-white flex items-center space-x-2 mt-0.5">
|
||
<span>{user ? 'Pro Telemetry Tier' : 'Guest Tier'}</span>
|
||
<span className="text-xs font-mono bg-slate-700 text-slate-300 px-2 py-0.5 rounded border border-slate-600 font-medium">
|
||
{user ? 'Pro Account' : 'Web billing unavailable'}
|
||
</span>
|
||
</h3>
|
||
</div>
|
||
<span className="text-xs font-mono text-slate-400">{user ? 'Active telemetry tier' : 'No active plan data'}</span>
|
||
</div>
|
||
{/* Inclusions Pills */}
|
||
<div className="mb-5">
|
||
<span className="text-xs font-mono text-slate-400 tracking-normal block mb-2">Entitlements & Features</span>
|
||
<div className="flex flex-wrap gap-2 text-xs font-mono">
|
||
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
|
||
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
|
||
<span>Phone-based ride recording</span>
|
||
</span>
|
||
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
|
||
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
|
||
<span>OBD-II / ECU telemetry unavailable</span>
|
||
</span>
|
||
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
|
||
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
|
||
<span>Cloud sync status shown below</span>
|
||
</span>
|
||
<span className="px-2.5 py-1 rounded-md bg-brand-cardAlt border border-brand-border text-slate-300 flex items-center space-x-1.5">
|
||
<svg className="w-3 h-3 text-brand-cyan" fill="currentColor" viewBox="0 0 20 20"><path clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" fillRule="evenodd"></path></svg>
|
||
<span>Emergency dispatch is not provided</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{/* Linked Hardware Details */}
|
||
<div className="p-3.5 bg-brand-cardAlt rounded-lg border border-brand-border mb-4 font-mono text-xs flex flex-col sm:flex-row justify-between gap-3">
|
||
<div>
|
||
<span className="text-slate-400 block text-[11px]">Motorcycle hardware</span>
|
||
<span className="font-bold text-white">Not available</span>
|
||
</div>
|
||
<div className="sm:text-right">
|
||
<span className="text-slate-400 block text-[11px]">Device status</span>
|
||
<span className="text-slate-300 font-semibold">No device linked</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-3 pt-2">
|
||
<Link
|
||
href="/group-rides"
|
||
className="px-4 py-2 rounded-lg bg-brand-cyan/15 text-brand-cyan border border-brand-cyan/40 hover:bg-brand-cyan/25 text-xs font-semibold transition inline-flex items-center gap-1.5"
|
||
>
|
||
<span className="material-symbols-outlined text-[16px]">groups</span>
|
||
<span>Group Rides Hub</span>
|
||
</Link>
|
||
<Link
|
||
href="/clubs/manage"
|
||
className="px-4 py-2 rounded-lg bg-secondary/15 text-secondary border border-secondary/40 hover:bg-secondary/25 text-xs font-semibold transition inline-flex items-center gap-1.5"
|
||
>
|
||
<span className="material-symbols-outlined text-[16px]">shield</span>
|
||
<span>Founder Portal</span>
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={handleManagePlan}
|
||
disabled={isBillingLoading}
|
||
title={
|
||
user
|
||
? 'Manage your subscription tier, payment methods, and invoices'
|
||
: 'Sign in to manage your subscription plan and invoices'
|
||
}
|
||
className="px-4 py-2 rounded-lg bg-brand-cardAlt border border-brand-border hover:border-slate-500 text-xs font-semibold text-white transition flex items-center space-x-1.5 cursor-pointer disabled:opacity-60"
|
||
>
|
||
{isBillingLoading && (
|
||
<svg className="w-3.5 h-3.5 animate-spin text-brand-cyan" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||
</svg>
|
||
)}
|
||
<span>Manage Plan & Invoices</span>
|
||
</button>
|
||
<button className="px-4 py-2 rounded-lg bg-cyan-500/10 text-brand-cyan border border-brand-cyan/40 hover:bg-cyan-500/20 text-xs font-semibold transition">
|
||
Hardware diagnostics unavailable
|
||
</button>
|
||
</div>
|
||
{billingNotice && (
|
||
<div
|
||
role="status"
|
||
className={`mt-4 p-3 rounded-lg border text-xs font-mono flex items-start space-x-2 ${
|
||
billingNotice.type === 'error'
|
||
? 'bg-rose-500/10 border-rose-500/30 text-rose-300'
|
||
: 'bg-brand-cyan/10 border-brand-cyan/30 text-brand-cyan'
|
||
}`}
|
||
>
|
||
<svg className="w-4 h-4 shrink-0 mt-0.5 text-rose-400" fill="currentColor" viewBox="0 0 20 20">
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
<span className="flex-1">{billingNotice.text}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBillingNotice(null)}
|
||
className="text-slate-400 hover:text-white ml-2 cursor-pointer font-bold"
|
||
aria-label="Dismiss message"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Right 5 cols: Connected Apps & Cluster Sync */}
|
||
<div className="lg:col-span-5 glass-card rounded-xl border border-brand-border p-6 flex flex-col justify-between" data-purpose="app-and-cluster-connections">
|
||
<div>
|
||
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-4">
|
||
<span className="text-xs font-mono tracking-normal text-brand-cyan">App Connections & Sync</span>
|
||
<span className={`text-xs font-mono flex items-center space-x-1.5 ${user ? 'text-brand-emerald' : 'text-slate-400'}`}>
|
||
<span className={`w-1.5 h-1.5 rounded-full ${user ? 'bg-brand-emerald' : 'bg-slate-500'}`}></span>
|
||
<span>{user ? 'Account linked' : 'Not signed in'}</span>
|
||
</span>
|
||
</div>
|
||
{/* Sync List */}
|
||
<div className="space-y-3 font-mono text-xs">
|
||
{/* Google App Account (real: reflects signed-in Firebase user) */}
|
||
<div className="p-3 bg-brand-cardAlt/70 rounded-lg border border-brand-border flex items-center justify-between">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center font-bold text-slate-300">
|
||
G
|
||
</div>
|
||
<div>
|
||
<span className="text-white block font-sans font-medium">Google Account</span>
|
||
<span className="text-slate-400 text-[11px]">{user?.email || 'Not connected'}</span>
|
||
</div>
|
||
</div>
|
||
<span className={`text-[11px] px-2 py-0.5 rounded border ${user ? 'text-brand-emerald bg-emerald-500/10 border-emerald-500/20' : 'text-slate-400 bg-slate-800 border-slate-700'}`}>
|
||
{user ? 'Connected' : 'Not Connected'}
|
||
</span>
|
||
</div>
|
||
{/* Authenticated devices (real: from /api/account/sessions) */}
|
||
<div className="p-3 bg-brand-cardAlt/70 rounded-lg border border-brand-border flex items-center justify-between">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-8 h-8 rounded-lg bg-cyan-500/15 text-brand-cyan flex items-center justify-center">
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"></path></svg>
|
||
</div>
|
||
<div>
|
||
<span className="text-white block font-sans font-medium">Authenticated Devices</span>
|
||
<span className="text-slate-400 text-[11px]">
|
||
{!user ? 'Sign in to view linked devices' : isLoadingSessions ? 'Checking sessions…' : 'Manage in Active Device Sessions below'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span className={`text-[11px] px-2 py-0.5 rounded border ${user && sessions.length > 0 ? 'text-brand-emerald bg-emerald-500/10 border-emerald-500/20' : 'text-slate-400 bg-slate-800 border-slate-700'}`}>
|
||
{!user ? '—' : isLoadingSessions ? '…' : `${sessions.length} active`}
|
||
</span>
|
||
</div>
|
||
{/* Helmet comms / accessories — Bluetooth pairing is not observable from a browser */}
|
||
<div className="p-3 bg-brand-cardAlt/70 rounded-lg border border-brand-border flex items-center justify-between">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-8 h-8 rounded-lg bg-slate-800 text-slate-300 flex items-center justify-center">
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 100-6 3 3 0 000 6z" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"></path></svg>
|
||
</div>
|
||
<div>
|
||
<span className="text-white block font-sans font-medium">Helmet Comms & Accessories</span>
|
||
<span className="text-slate-400 text-[11px]">Bluetooth pairing is managed in the mobile app</span>
|
||
</div>
|
||
</div>
|
||
<span className="text-slate-400 text-[11px] bg-slate-800 px-2 py-0.5 rounded border border-slate-700">Unavailable on web</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="pt-3 flex items-center justify-between text-xs font-mono">
|
||
<span className="text-slate-400">Apple Watch Companion: <span className="text-slate-400 font-semibold">Not Connected (Unavailable on Web)</span></span>
|
||
<a href="#active-device-sessions" className="text-brand-cyan hover:underline font-sans font-medium">Manage Devices</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{/* END: PlanAndClusterSection */}
|
||
{/* BEGIN: SecurityAndSessionsGrid */}
|
||
<section className="grid grid-cols-1 lg:grid-cols-12 gap-6" data-purpose="security-and-session-management">
|
||
{/* Sessions (7 cols) */}
|
||
<div id="active-device-sessions" className="lg:col-span-7 glass-card rounded-xl border border-brand-border p-6 scroll-mt-24" data-purpose="active-browser-sessions">
|
||
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-4">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-white">Active Device Sessions</h3>
|
||
<p className="text-xs font-mono text-slate-400 mt-0.5">Authenticated tokens accessing MotorSignal account telemetry</p>
|
||
</div>
|
||
<span className="text-xs font-mono text-brand-cyan bg-brand-cyan/10 border border-brand-cyan/30 px-2.5 py-1 rounded-full">
|
||
{sessions.length} {sessions.length === 1 ? 'Session' : 'Sessions'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="session-manager space-y-4">
|
||
<div className="session-heading flex items-center justify-between pb-2 border-b border-brand-border/60">
|
||
<div className="flex items-center space-x-2">
|
||
<h2 className="text-sm font-bold text-white font-mono">Sessions</h2>
|
||
{sessions.length > 0 && (
|
||
<span className="text-xs font-mono text-slate-400">
|
||
(Showing {Math.min(visibleSessionCount, sessions.length)} of {sessions.length})
|
||
</span>
|
||
)}
|
||
</div>
|
||
{sessions.length > 0 && (
|
||
<button
|
||
className="text-button text-xs font-mono text-rose-400 hover:text-rose-300 transition cursor-pointer"
|
||
onClick={() => void handleRevokeAllSessions()}
|
||
type="button"
|
||
>
|
||
Revoke all
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{sessions.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{sessions.slice(0, visibleSessionCount).map((session) => (
|
||
<div
|
||
className="session-row p-3 bg-brand-cardAlt/70 rounded-lg border border-brand-border flex items-center justify-between font-mono text-xs"
|
||
key={session.id}
|
||
>
|
||
<div>
|
||
<strong className="text-white block font-medium">
|
||
{session.device_name ?? 'Unnamed device'} {session.current && '(this device)'}
|
||
</strong>
|
||
<p className="text-slate-400 text-[11px] mt-0.5">
|
||
Created {new Date(session.created_at).toLocaleString()}
|
||
</p>
|
||
</div>
|
||
{!session.current && (
|
||
<button
|
||
className="text-button text-xs font-mono text-rose-400 hover:text-rose-300 px-2.5 py-1 rounded bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 transition cursor-pointer"
|
||
onClick={() => void handleRevokeSession(session.id)}
|
||
type="button"
|
||
>
|
||
Revoke
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{visibleSessionCount < sessions.length && (
|
||
<div className="pt-3 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => setVisibleSessionCount((prev) => prev + 10)}
|
||
className="px-4 py-2 rounded-lg bg-brand-cardAlt hover:bg-slate-800 text-brand-cyan text-xs font-mono font-semibold border border-brand-cyan/40 hover:border-brand-cyan transition cursor-pointer"
|
||
>
|
||
Show more
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<p className="text-slate-400 text-xs font-mono">
|
||
{isLoadingSessions ? 'Loading sessions...' : 'No active sessions.'}
|
||
</p>
|
||
)}
|
||
|
||
{sessionMessage && (
|
||
<p className="form-message text-xs font-mono text-brand-emerald bg-emerald-500/10 border border-emerald-500/20 p-2.5 rounded" role="status">
|
||
{sessionMessage}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Security & SOS Preferences (5 cols) */}
|
||
<div className="lg:col-span-5 glass-card rounded-xl border border-brand-border p-6 flex flex-col justify-between" data-purpose="safety-and-security">
|
||
<div>
|
||
<div className="border-b border-brand-border pb-4 mb-4">
|
||
<h3 className="text-lg font-bold text-white">Account & Security</h3>
|
||
<p className="text-xs font-mono text-slate-400 mt-0.5">Account-link status and security settings</p>
|
||
</div>
|
||
<div className="space-y-4 text-xs font-mono">
|
||
{/* Google App Link */}
|
||
<div className="p-3 rounded-lg bg-brand-cardAlt border border-brand-border">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-white font-bold block">Mobile App Account Link</span>
|
||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${user ? 'text-brand-emerald bg-emerald-500/10 border border-emerald-500/20' : 'text-slate-400 bg-slate-800'}`}>
|
||
{user ? 'CONNECTED' : 'NOT LINKED'}
|
||
</span>
|
||
</div>
|
||
<FirebaseLink linked={!!user} />
|
||
</div>
|
||
{/* 2FA State — reflects real Firebase multi-factor enrollment; enrollment is managed in the mobile app */}
|
||
<div className="flex items-center justify-between p-3 rounded-lg bg-brand-cardAlt border border-brand-border">
|
||
<div>
|
||
<span className="text-white font-bold block">Two-Factor Authentication (2FA)</span>
|
||
<span className="text-slate-400 text-[11px]">{twoFactorEnrolled ? 'Authenticator enrolled' : 'Enroll in the mobile app'}</span>
|
||
</div>
|
||
{twoFactorEnrolled ? (
|
||
<span className="text-brand-emerald bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 font-bold">ENABLED</span>
|
||
) : (
|
||
<span className="text-slate-400 bg-slate-800 px-2 py-0.5 rounded border border-slate-700 font-bold">NOT ENABLED</span>
|
||
)}
|
||
</div>
|
||
{/* Leaderboard Privacy — managed in the mobile app; the web app has no privacy toggle backend */}
|
||
<div className="flex items-center justify-between p-3 rounded-lg bg-brand-cardAlt border border-brand-border">
|
||
<div>
|
||
<span className="text-white font-bold block">Telemetry Privacy Mode</span>
|
||
<span className="text-slate-400 text-[11px]">Leaderboard visibility is managed in the mobile app</span>
|
||
</div>
|
||
<span className="text-slate-400 text-[11px] bg-slate-800 px-2 py-0.5 rounded border border-slate-700">In mobile app</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="pt-4 border-t border-brand-border mt-4 flex items-center justify-between text-xs">
|
||
<span className="text-slate-400 font-mono">Telemetry Data Retention: 100% Retained</span>
|
||
<a className="text-brand-cyan hover:underline font-mono" href="#active-device-sessions">Review active sessions</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{/* END: SecurityAndSessionsGrid */}
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|