feat(tesla): client-side mock + driving-mode card
- teslaMock: ?mockTesla=parked|driving|charging|asleep returns canned state, persisted via localStorage. Bypasses Fleet API entirely; short-circuited off in import.meta.env.PROD so prod can't enable it. Driving scenario animates battery + position so the UI feels alive. - useTesla polls every 3s when mocking (vs 60s real) so animation works. - MOCK badge in the top bar cycles scenarios in-place. - DrivingMode: full-screen takeover when shiftState != P (or ?drivingMode=1). Battery + range chip, big speed readout, hero next-stop card with name, distance, ETA, "send to Tesla nav" button, charging strip at the bottom when plugged in, exit button for debug. Picks nearest planned stop to the car's current GPS as the "next stop".
This commit is contained in:
+16
-1
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { isMockEnabled, getMockStatus, getMockState } from './teslaMock';
|
||||||
|
|
||||||
export interface TeslaStatus {
|
export interface TeslaStatus {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -18,6 +19,7 @@ export interface OwnerStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOwnerStatus(): Promise<OwnerStatus> {
|
export async function fetchOwnerStatus(): Promise<OwnerStatus> {
|
||||||
|
if (isMockEnabled()) return { authenticated: true, required: true };
|
||||||
const res = await fetch('/api/auth/owner/status');
|
const res = await fetch('/api/auth/owner/status');
|
||||||
if (!res.ok) return { authenticated: false, required: false };
|
if (!res.ok) return { authenticated: false, required: false };
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -60,12 +62,14 @@ export interface TeslaState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTeslaStatus(): Promise<TeslaStatus> {
|
export async function fetchTeslaStatus(): Promise<TeslaStatus> {
|
||||||
|
if (isMockEnabled()) return getMockStatus();
|
||||||
const res = await fetch('/api/tesla/status');
|
const res = await fetch('/api/tesla/status');
|
||||||
if (!res.ok) return { available: false, connected: false };
|
if (!res.ok) return { available: false, connected: false };
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTeslaState(): Promise<TeslaState | null> {
|
export async function fetchTeslaState(): Promise<TeslaState | null> {
|
||||||
|
if (isMockEnabled()) return getMockState();
|
||||||
const res = await fetch('/api/tesla/state');
|
const res = await fetch('/api/tesla/state');
|
||||||
if (res.status === 202) return null; // asleep
|
if (res.status === 202) return null; // asleep
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
@@ -73,11 +77,16 @@ export async function fetchTeslaState(): Promise<TeslaState | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function wakeTesla(): Promise<boolean> {
|
export async function wakeTesla(): Promise<boolean> {
|
||||||
|
if (isMockEnabled()) return true;
|
||||||
const res = await fetch('/api/tesla/wake', { method: 'POST' });
|
const res = await fetch('/api/tesla/wake', { method: 'POST' });
|
||||||
return res.ok;
|
return res.ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startTeslaConnect(): Promise<void> {
|
export async function startTeslaConnect(): Promise<void> {
|
||||||
|
if (isMockEnabled()) {
|
||||||
|
window.location.href = `${window.location.pathname}?tesla_connected=1`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await fetch('/api/auth/tesla/start');
|
const res = await fetch('/api/auth/tesla/start');
|
||||||
if (!res.ok) throw new Error('Could not start Tesla OAuth');
|
if (!res.ok) throw new Error('Could not start Tesla OAuth');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -85,10 +94,15 @@ export async function startTeslaConnect(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function disconnectTesla(): Promise<void> {
|
export async function disconnectTesla(): Promise<void> {
|
||||||
|
if (isMockEnabled()) return;
|
||||||
await fetch('/api/tesla/disconnect', { method: 'POST' });
|
await fetch('/api/tesla/disconnect', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise<boolean> {
|
export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise<boolean> {
|
||||||
|
if (isMockEnabled()) {
|
||||||
|
console.log('[mock] sendToTeslaNav', args);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const res = await fetch('/api/tesla/send-to-nav', {
|
const res = await fetch('/api/tesla/send-to-nav', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -133,7 +147,8 @@ export function useTesla() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
pollRef.current = setInterval(tick, 60_000);
|
// Poll faster when mocking so the driving scenario animates visibly.
|
||||||
|
pollRef.current = setInterval(tick, isMockEnabled() ? 3_000 : 60_000);
|
||||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
||||||
}, [status?.connected, owner?.authenticated]);
|
}, [status?.connected, owner?.authenticated]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Client-side mock for the Tesla integration so we can iterate on UI without
|
||||||
|
// burning Fleet API calls (or sitting in the car).
|
||||||
|
//
|
||||||
|
// Activate via URL:
|
||||||
|
// ?mockTesla=parked parked, 65% battery, in Milton Keynes
|
||||||
|
// ?mockTesla=driving moving at 110 km/h, battery slowly draining
|
||||||
|
// ?mockTesla=charging plugged in at a Supercharger, 132 kW
|
||||||
|
// ?mockTesla=asleep connected but car asleep (state returns null)
|
||||||
|
// ?mockTesla=off disable mock (delete the stored flag)
|
||||||
|
//
|
||||||
|
// The flag persists in localStorage so reloads keep the scenario.
|
||||||
|
// Dev-only: import.meta.env.PROD short-circuits this off in the bundled build,
|
||||||
|
// so production users can't enable mock mode.
|
||||||
|
|
||||||
|
import type { TeslaStatus, TeslaState } from './tesla';
|
||||||
|
|
||||||
|
export type MockScenario = 'parked' | 'driving' | 'charging' | 'asleep';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mockTesla';
|
||||||
|
|
||||||
|
export function isMockEnabled(): boolean {
|
||||||
|
return !!getMockScenario();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMockScenario(): MockScenario | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if (import.meta.env.PROD) return null; // never in prod build
|
||||||
|
|
||||||
|
const urlParam = new URLSearchParams(window.location.search).get('mockTesla');
|
||||||
|
if (urlParam === 'off') {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (urlParam && isValidScenario(urlParam)) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, urlParam);
|
||||||
|
return urlParam;
|
||||||
|
}
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored && isValidScenario(stored) ? stored : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidScenario(s: string): s is MockScenario {
|
||||||
|
return s === 'parked' || s === 'driving' || s === 'charging' || s === 'asleep';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMockScenario(s: MockScenario | null): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (s == null) window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
else window.localStorage.setItem(STORAGE_KEY, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Canned vehicle identity ─────────────────────────────────────────────────
|
||||||
|
const MOCK_VEHICLE = {
|
||||||
|
vin: '5YJ3E1EA1KF0MOCKED',
|
||||||
|
carType: 'modely',
|
||||||
|
trimBadging: 'lrawd',
|
||||||
|
vehicleName: 'Mocky',
|
||||||
|
softwareVersion: '2026.5.14',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base location: Milton Keynes, MK7 8PJ
|
||||||
|
const BASE_LAT = 52.0186;
|
||||||
|
const BASE_LNG = -0.7064;
|
||||||
|
|
||||||
|
// Drift state for the "driving" scenario — battery slowly drains, position
|
||||||
|
// shuffles a bit so map markers move.
|
||||||
|
let driveBattery = 64;
|
||||||
|
let driveTickCount = 0;
|
||||||
|
|
||||||
|
export function getMockStatus(): TeslaStatus {
|
||||||
|
const s = getMockScenario();
|
||||||
|
if (!s) return { available: true, connected: false, ownerAuthenticated: true };
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
connected: true,
|
||||||
|
ownerAuthenticated: true,
|
||||||
|
connectedAt: Date.now() - 60 * 60 * 1000,
|
||||||
|
vehicleId: 'mock-1',
|
||||||
|
vin: `…${MOCK_VEHICLE.vin.slice(-4)}`,
|
||||||
|
carType: MOCK_VEHICLE.carType,
|
||||||
|
trimBadging: MOCK_VEHICLE.trimBadging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMockState(): TeslaState | null {
|
||||||
|
const s = getMockScenario();
|
||||||
|
if (!s || s === 'asleep') return null;
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
connected: true,
|
||||||
|
asleep: false,
|
||||||
|
carType: MOCK_VEHICLE.carType,
|
||||||
|
trimBadging: MOCK_VEHICLE.trimBadging,
|
||||||
|
vin: `…${MOCK_VEHICLE.vin.slice(-4)}`,
|
||||||
|
vehicleName: MOCK_VEHICLE.vehicleName,
|
||||||
|
softwareVersion: MOCK_VEHICLE.softwareVersion,
|
||||||
|
odometerKm: 24_500,
|
||||||
|
heading: s === 'driving' ? 92 : 0,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (s === 'parked') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
battery: 65,
|
||||||
|
rangeKm: 320,
|
||||||
|
idealRangeKm: 330,
|
||||||
|
chargingState: 'Disconnected',
|
||||||
|
chargerPowerKw: null,
|
||||||
|
timeToFullCharge: null,
|
||||||
|
lat: BASE_LAT,
|
||||||
|
lng: BASE_LNG,
|
||||||
|
speedKmh: 0,
|
||||||
|
shiftState: 'P' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s === 'charging') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
battery: 47,
|
||||||
|
rangeKm: 240,
|
||||||
|
idealRangeKm: 250,
|
||||||
|
chargingState: 'Charging',
|
||||||
|
chargerPowerKw: 132,
|
||||||
|
timeToFullCharge: 0.55, // ~33 min
|
||||||
|
lat: 52.0461, // Baldock services
|
||||||
|
lng: -0.2024,
|
||||||
|
speedKmh: 0,
|
||||||
|
shiftState: 'P' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// driving — animate battery + position with each tick
|
||||||
|
driveTickCount++;
|
||||||
|
driveBattery = Math.max(8, driveBattery - 0.18);
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
battery: Math.round(driveBattery),
|
||||||
|
rangeKm: Math.round(driveBattery * 4.9),
|
||||||
|
idealRangeKm: Math.round(driveBattery * 5.1),
|
||||||
|
chargingState: 'Disconnected',
|
||||||
|
chargerPowerKw: null,
|
||||||
|
timeToFullCharge: null,
|
||||||
|
// Crude eastbound drift across the East Anglian flatlands.
|
||||||
|
lat: BASE_LAT - 0.002 * driveTickCount,
|
||||||
|
lng: BASE_LNG + 0.008 * driveTickCount,
|
||||||
|
speedKmh: 112,
|
||||||
|
shiftState: 'D' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the running driving simulation back to start. */
|
||||||
|
export function resetMockDrive(): void {
|
||||||
|
driveBattery = 64;
|
||||||
|
driveTickCount = 0;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
||||||
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner } from '../lib/tesla';
|
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner } from '../lib/tesla';
|
||||||
|
import { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock';
|
||||||
import { detectInCar } from '../lib/incar';
|
import { detectInCar } from '../lib/incar';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import {
|
import {
|
||||||
@@ -1597,6 +1598,38 @@ function TopBar({
|
|||||||
{grokStatus.label}
|
{grokStatus.label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<MockTeslaIndicator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MockTeslaIndicator() {
|
||||||
|
const [scenario, setScenarioState] = React.useState(getMockScenario());
|
||||||
|
if (!scenario) return null;
|
||||||
|
const cycle = () => {
|
||||||
|
const next = scenario === 'parked' ? 'driving'
|
||||||
|
: scenario === 'driving' ? 'charging'
|
||||||
|
: scenario === 'charging' ? 'asleep'
|
||||||
|
: scenario === 'asleep' ? 'parked'
|
||||||
|
: 'parked';
|
||||||
|
setMockScenario(next);
|
||||||
|
resetMockDrive();
|
||||||
|
setScenarioState(next);
|
||||||
|
// Hard reload so all hooks pick up the new scenario cleanly.
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
const disable = () => {
|
||||||
|
setMockScenario(null);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="text-[10px] px-2 py-1 rounded inline-flex items-center gap-1.5"
|
||||||
|
style={{ background: 'rgba(251,191,36,0.15)', color: 'var(--gd-amber)', border: '1px solid rgba(251,191,36,0.45)' }}
|
||||||
|
>
|
||||||
|
<span className="font-semibold tracking-wider uppercase">MOCK</span>
|
||||||
|
<button onClick={cycle} className="underline" title="Cycle scenario">{scenario}</button>
|
||||||
|
<button onClick={disable} className="opacity-70 hover:opacity-100" title="Disable mock">×</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2172,8 +2205,51 @@ export default function TeslaTripPlanner() {
|
|||||||
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
|
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
|
||||||
const dateLabels = ['Today', 'Tomorrow'];
|
const dateLabels = ['Today', 'Tomorrow'];
|
||||||
|
|
||||||
|
// Driving-mode trigger: car is moving (or in a non-Park gear). Also enabled
|
||||||
|
// via ?drivingMode=1 for development.
|
||||||
|
const forceDrivingMode = React.useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return new URLSearchParams(window.location.search).get('drivingMode') === '1';
|
||||||
|
}, []);
|
||||||
|
const isDriving = forceDrivingMode || (
|
||||||
|
!!tesla.state?.shiftState && tesla.state.shiftState !== 'P'
|
||||||
|
);
|
||||||
|
// Closest planned stop to the car's current position, used by driving mode.
|
||||||
|
const nextStop = React.useMemo(() => {
|
||||||
|
if (!isDriving || !tesla.state?.lat || !tesla.state?.lng) return null;
|
||||||
|
let best: { stop: Stop; km: number } | null = null;
|
||||||
|
for (const s of allStops) {
|
||||||
|
const km = haversineKm(
|
||||||
|
{ lat: tesla.state.lat, lng: tesla.state.lng },
|
||||||
|
{ lat: s.lat, lng: s.lng },
|
||||||
|
);
|
||||||
|
if (best === null || km < best.km) best = { stop: s, km };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}, [isDriving, tesla.state?.lat, tesla.state?.lng, allStops]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
{isDriving && (
|
||||||
|
<DrivingMode
|
||||||
|
tesla={tesla}
|
||||||
|
nextStop={nextStop}
|
||||||
|
vehicle={vehicle}
|
||||||
|
onSendToNav={async () => {
|
||||||
|
if (!nextStop) return;
|
||||||
|
const ok = await sendToTeslaNav({ lat: nextStop.stop.lat, lng: nextStop.stop.lng, name: nextStop.stop.name });
|
||||||
|
if (ok) toast.success(`Sent ${nextStop.stop.name} to Tesla nav`);
|
||||||
|
else toast.error('Could not send');
|
||||||
|
}}
|
||||||
|
onExit={() => {
|
||||||
|
// Force-exit driving mode (debug). Strips ?drivingMode=1 if present.
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('drivingMode');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-col h-screen overflow-hidden" style={{ background: 'var(--gd-bg)', color: 'var(--gd-text)' }}>
|
<div className="flex flex-col h-screen overflow-hidden" style={{ background: 'var(--gd-bg)', color: 'var(--gd-text)' }}>
|
||||||
<TopBar
|
<TopBar
|
||||||
origin={origin} destination={destination}
|
origin={origin} destination={destination}
|
||||||
@@ -2663,6 +2739,154 @@ export default function TeslaTripPlanner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Driving mode ────────────────────────────────────────────────────────────
|
||||||
|
// Full-screen overlay when the car is moving. Huge typography, minimal chrome,
|
||||||
|
// only one possible action (Send next stop to nav).
|
||||||
|
function DrivingMode({ tesla, nextStop, vehicle, onSendToNav, onExit }: {
|
||||||
|
tesla: ReturnType<typeof useTesla>;
|
||||||
|
nextStop: { stop: Stop; km: number } | null;
|
||||||
|
vehicle: Vehicle;
|
||||||
|
onSendToNav: () => void;
|
||||||
|
onExit: () => void;
|
||||||
|
}) {
|
||||||
|
const state = tesla.state;
|
||||||
|
const battery = state?.battery ?? null;
|
||||||
|
const rangeKm = state?.rangeKm ?? null;
|
||||||
|
const speed = state?.speedKmh ?? null;
|
||||||
|
const distanceKm = nextStop?.km ?? null;
|
||||||
|
const reqPctRough = distanceKm != null
|
||||||
|
? Math.max(5, Math.round((distanceKm / Math.max(50, vehicle.rangeKm)) * 100))
|
||||||
|
: null;
|
||||||
|
const enough = battery != null && reqPctRough != null ? battery >= reqPctRough + 8 : null;
|
||||||
|
// ETA roughly distance ÷ assumed 95 km/h motorway, expressed as a clock time.
|
||||||
|
const etaText = React.useMemo(() => {
|
||||||
|
if (distanceKm == null) return null;
|
||||||
|
const avgKmh = speed && speed > 30 ? Math.max(60, speed) : 95;
|
||||||
|
const minutes = Math.round((distanceKm / avgKmh) * 60);
|
||||||
|
const arrive = new Date(Date.now() + minutes * 60_000);
|
||||||
|
return `${arrive.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} · ${minutes}m`;
|
||||||
|
}, [distanceKm, speed]);
|
||||||
|
|
||||||
|
const meta = nextStop ? stopMeta(nextStop.stop.type) : null;
|
||||||
|
const Icon = meta?.icon ?? MapPin;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex flex-col"
|
||||||
|
style={{ zIndex: 9997, background: '#0a0a0c', color: 'var(--gd-text)' }}
|
||||||
|
>
|
||||||
|
{/* Top strip — battery + speed + exit */}
|
||||||
|
<div className="px-8 py-5 flex items-center gap-6" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="grid place-items-center rounded-2xl"
|
||||||
|
style={{ width: 68, height: 44, background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border-2)' }}
|
||||||
|
>
|
||||||
|
<div className="text-[22px] font-semibold num" style={{ color: battery != null && battery < 20 ? 'var(--gd-red)' : 'var(--gd-green)' }}>
|
||||||
|
{battery != null ? `${battery}%` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="leading-[1.15]">
|
||||||
|
<div className="text-[13px]" style={{ color: 'var(--gd-text-3)' }}>Range</div>
|
||||||
|
<div className="text-[20px] num font-medium">{rangeKm != null ? `${rangeKm} km` : '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<div className="text-[12px] uppercase tracking-[0.2em]" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
Driving · {state?.shiftState ?? 'D'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[42px] num font-semibold leading-[1]">{speed ?? 0}</div>
|
||||||
|
<div className="text-[12px]" style={{ color: 'var(--gd-text-3)' }}>km/h</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onExit}
|
||||||
|
className="text-[12px] px-3 py-2 rounded-lg"
|
||||||
|
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
|
||||||
|
title="Exit driving mode"
|
||||||
|
>
|
||||||
|
Exit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Centre — next stop hero */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center px-8">
|
||||||
|
{nextStop ? (
|
||||||
|
<>
|
||||||
|
<div className="text-[14px] uppercase tracking-[0.25em] mb-5" style={{ color: 'var(--gd-red)' }}>
|
||||||
|
Next stop
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-24 h-24 rounded-3xl grid place-items-center mb-6"
|
||||||
|
style={{ background: `${meta?.color || '#9ca3af'}22`, border: `1px solid ${meta?.color || '#9ca3af'}55` }}
|
||||||
|
>
|
||||||
|
<Icon size={44} style={{ color: meta?.color }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[42px] font-semibold text-center leading-[1.1] mb-3 max-w-[80vw]">
|
||||||
|
{nextStop.stop.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 mb-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>Distance</div>
|
||||||
|
<div className="text-[34px] num font-semibold">{Math.round(distanceKm ?? 0)} km</div>
|
||||||
|
</div>
|
||||||
|
{etaText && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>Arrive</div>
|
||||||
|
<div className="text-[34px] num font-semibold">{etaText}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{enough != null && (
|
||||||
|
<div
|
||||||
|
className="text-[14px] px-4 py-2 rounded-full mb-6"
|
||||||
|
style={{
|
||||||
|
background: enough ? 'rgba(74,222,128,0.12)' : 'rgba(227,25,55,0.16)',
|
||||||
|
color: enough ? 'var(--gd-green)' : 'var(--gd-red)',
|
||||||
|
border: `1px solid ${enough ? 'rgba(74,222,128,0.35)' : 'rgba(227,25,55,0.45)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enough ? `Enough battery — ~${reqPctRough}% needed` : `Low battery — need ~${reqPctRough}% to reach this stop`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onSendToNav}
|
||||||
|
className="h-16 px-10 rounded-2xl text-[20px] font-semibold inline-flex items-center gap-3"
|
||||||
|
style={{ background: 'var(--gd-red)', color: '#fff', boxShadow: '0 8px 28px rgba(227,25,55,0.35)' }}
|
||||||
|
>
|
||||||
|
<Navigation className="w-6 h-6" /> Send to Tesla nav
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[18px] mb-2" style={{ color: 'var(--gd-text-2)' }}>No trip loaded</div>
|
||||||
|
<div className="text-[13px]" style={{ color: 'var(--gd-text-3)' }}>Plan a trip while parked and it'll pick up from here.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom — charging status if plugged in */}
|
||||||
|
{state?.chargingState === 'Charging' && (
|
||||||
|
<div className="px-8 py-4 flex items-center gap-6" style={{ borderTop: '1px solid var(--gd-border)', background: 'rgba(74,222,128,0.05)' }}>
|
||||||
|
<Zap className="w-6 h-6" style={{ color: 'var(--gd-green)' }} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[12px] uppercase tracking-wider" style={{ color: 'var(--gd-green)' }}>Charging</div>
|
||||||
|
<div className="text-[20px] num">
|
||||||
|
{state.chargerPowerKw ?? '—'} kW
|
||||||
|
{state.timeToFullCharge != null && (
|
||||||
|
<span className="text-[13px] ml-3" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
· {Math.round(state.timeToFullCharge * 60)}m to target
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function OwnerLoginModal({ open, onClose, onSuccess }: {
|
function OwnerLoginModal({ open, onClose, onSuccess }: {
|
||||||
open: boolean; onClose: () => void; onSuccess: () => void;
|
open: boolean; onClose: () => void; onSuccess: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user