Files
tesla-roadtrip/client/src/pages/TeslaTripPlanner.tsx
T
tony f793b526aa fix(security): owner auth gate, OAuth state cookie binding, 0600 token perms
- Add OWNER_SECRET-based session: signed HMAC cookie, /api/auth/owner login,
  requireOwner middleware. All Tesla routes refuse 401 without it.
- Bind OAuth state to a SameSite=Lax httpOnly cookie at /start, validate
  match in /callback with constant-time compare. Refuses unmatched callbacks.
- Token store now mkdir 0700, writeFile + rename atomic, mode 0600 with
  defensive chmod. Owner-only on disk.
- VIN masked to last 4 in responses; partner-register no longer echoes raw
  Tesla body to clients; coord bounds checked on send-to-nav.
- Client: useTesla also tracks owner status; Connect Tesla button opens an
  OwnerLoginModal when not authenticated, then continues to Tesla OAuth.

Conscious deferrals:
- Explicit CSRF tokens on POST routes: mitigated by SameSite=Lax cookies
  + same-origin CORS. Will revisit if cross-origin clients land.
- At-rest token encryption: deferred for single-user app; tokens are on a
  0700 Dokku volume readable only by the app uid. Will add AES-GCM if we
  multi-tenant.
2026-05-31 22:32:22 +01:00

3885 lines
168 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner } from '../lib/tesla';
import { detectInCar } from '../lib/incar';
import L from 'leaflet';
import {
Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X,
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown,
Car, Battery, Navigation,
} from 'lucide-react';
// Fix Leaflet default icons (we still need pins for non-active stops)
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
// ─── Types ───────────────────────────────────────────────────────────────────
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel' | 'ferry' | 'crossing';
interface NearbyPlace {
category: 'food' | 'do' | 'see' | 'shop' | 'rest';
icon: string;
name: string;
detail: string;
}
interface ChargerOption {
id: string;
name: string;
network?: string;
stalls: number;
kw: number;
pricePerKwh: number;
detourMin: number;
isCurrent?: boolean;
badge?: string | null;
}
interface CrossingOption {
id: string;
operator: string; // e.g. "Eurotunnel Le Shuttle", "DFDS", "P&O Ferries", "Brittany Ferries"
mode: 'tunnel' | 'ferry';
fromPort: string; // e.g. "Folkestone, UK"
toPort: string; // e.g. "Coquelles (Calais), FR"
durationMin: number;
priceEur: number; // ballpark single-vehicle one-way price
frequency?: string; // e.g. "every 30 min, 24/7"
pros?: string[];
cons?: string[];
badge?: 'Fastest' | 'Cheapest' | 'Most scenic' | 'Overnight' | 'Frequent' | null;
detourMin?: number; // vs the chosen crossing (in road-driving terms)
detourKm?: number;
isCurrent?: boolean;
bookingUrl?: string;
}
interface RouteVariant {
id: 'fast' | 'scenic' | 'cheap';
label: string;
tone: 'primary' | 'green' | 'blue';
distanceKm: number;
driveHours: number;
chargeHours: number;
costEur: number;
highlight?: 'drive' | 'cost' | 'pretty';
pros: string[];
}
interface AlternativeStop {
id: string;
name: string;
type: StopType;
lat: number;
lng: number;
description?: string;
combo?: string | null;
amenities?: string[];
cuisine?: string | null;
priceLevel?: number;
chargeMinutes?: number;
durationMin?: number;
deltaKm?: number;
deltaMin?: number;
reason?: string;
}
interface Stop {
id: string;
name: string;
type: StopType;
lat: number;
lng: number;
day: number;
order: number;
estArrivalBattery?: number;
chargeMinutes?: number;
durationMin?: number;
combo?: string | null;
description?: string;
amenities?: string[];
cuisine?: string | null;
priceLevel?: number;
notes?: string;
alternatives?: AlternativeStop[];
nearby?: NearbyPlace[];
chargerOptions?: ChargerOption[];
crossingOptions?: CrossingOption[];
}
interface TravelDates {
outbound: string | null; // ISO yyyy-mm-dd
return: string | null; // ISO yyyy-mm-dd (null if one-way)
travellers?: number;
}
interface Itinerary {
days: { day: number; title?: string; stops: Stop[] }[];
summary: {
totalDistanceKm: number;
estDriveHours: number;
estChargeHours: number;
superchargers: number;
hotels: number;
highlights?: string[];
};
needsTravelDates?: boolean;
}
interface Leg {
geometry: [number, number][];
distanceKm: number | null;
durationMin: number | null;
fromId: string;
toId: string;
}
const EMPTY_ITINERARY: Itinerary = {
days: [],
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0, highlights: [] },
};
const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel', 'ferry', 'crossing'];
interface VehicleTrim {
id: string;
name: string; // e.g. "Long Range AWD"
rangeKm: number;
kw: number;
sec0to60: number;
topKmh: number;
badge?: string;
}
interface VehicleModel {
id: string;
name: string; // e.g. "Model Y"
description: string;
trims: VehicleTrim[];
}
interface Vehicle {
modelId: string;
trimId: string;
name: string; // model name
trim: string; // trim name
rangeKm: number;
kw: number;
sec0to60: number;
topKmh: number;
badge?: string;
}
const TESLA_MODELS: VehicleModel[] = [
{
id: 'model-s', name: 'Model S', description: 'Fastback sedan',
trims: [
{ id: 'lr', name: 'Long Range', rangeKm: 634, kw: 250, sec0to60: 3.1, topKmh: 240 },
{ id: 'plaid', name: 'Plaid', rangeKm: 600, kw: 250, sec0to60: 1.99, topKmh: 322, badge: 'Performance' },
],
},
{
id: 'model-3', name: 'Model 3', description: 'Compact sedan',
trims: [
{ id: 'std', name: 'Standard Range', rangeKm: 438, kw: 175, sec0to60: 5.6, topKmh: 201 },
{ id: 'lr-rwd', name: 'Long Range RWD', rangeKm: 553, kw: 250, sec0to60: 4.9, topKmh: 201, badge: 'Best range' },
{ id: 'lr-awd', name: 'Long Range AWD', rangeKm: 528, kw: 250, sec0to60: 4.2, topKmh: 201 },
{ id: 'perf', name: 'Performance', rangeKm: 528, kw: 250, sec0to60: 2.9, topKmh: 261, badge: 'Performance' },
],
},
{
id: 'model-y', name: 'Model Y', description: 'Crossover · best-seller',
trims: [
{ id: 'std', name: 'Standard Range', rangeKm: 460, kw: 175, sec0to60: 5.6, topKmh: 217 },
{ id: 'lr-rwd', name: 'Long Range RWD', rangeKm: 531, kw: 250, sec0to60: 5.9, topKmh: 217 },
{ id: 'lr-awd', name: 'Long Range AWD', rangeKm: 514, kw: 250, sec0to60: 4.8, topKmh: 217, badge: 'Most popular' },
{ id: 'perf', name: 'Performance', rangeKm: 488, kw: 250, sec0to60: 3.5, topKmh: 250, badge: 'Performance' },
],
},
{
id: 'model-x', name: 'Model X', description: 'Three-row SUV · falcon doors',
trims: [
{ id: 'lr', name: 'Long Range', rangeKm: 543, kw: 250, sec0to60: 3.8, topKmh: 250 },
{ id: 'plaid', name: 'Plaid', rangeKm: 528, kw: 250, sec0to60: 2.5, topKmh: 262, badge: 'Performance' },
],
},
{
id: 'cybertruck', name: 'Cybertruck', description: 'Angular pickup',
trims: [
{ id: 'rwd', name: 'Long Range RWD', rangeKm: 563, kw: 350, sec0to60: 6.5, topKmh: 180 },
{ id: 'awd', name: 'AWD', rangeKm: 547, kw: 350, sec0to60: 4.1, topKmh: 180 },
{ id: 'beast', name: 'Cyberbeast', rangeKm: 515, kw: 350, sec0to60: 2.6, topKmh: 209, badge: 'Performance' },
],
},
];
const DEFAULT_VEHICLE: Vehicle = (() => {
const m = TESLA_MODELS.find(x => x.id === 'model-y')!;
const t = m.trims.find(tr => tr.id === 'lr-awd')!;
return { modelId: m.id, trimId: t.id, name: m.name, trim: t.name, rangeKm: t.rangeKm, kw: t.kw, sec0to60: t.sec0to60, topKmh: t.topKmh, badge: t.badge };
})();
function abbrevTrim(trim: string): string {
return trim
.replace('Long Range', 'LR')
.replace('Standard Range', 'Std')
.replace('Performance', 'Perf');
}
const QUICK_PROMPTS = [
'Plan a 2-day trip from London to Edinburgh in my Model Y',
'I want to drive from Amsterdam to Munich',
'Help me plan a scenic route from Paris to the Alps',
'Best way from Glasgow to London avoiding motorways',
];
// ─── Amenity icon map (centralised) ──────────────────────────────────────────
const AMENITY_ICONS: Record<string, string> = {
restaurant: '🍽️', cafe: '☕', 'fast-food': '🍔', supermarket: '🛒',
toilets: '🚻', shopping: '🛍️', wifi: '📶', playground: '🧒',
'ev-charging': '⚡', 'destination-charging': '🔌', hotel: '🛏️',
coffee: '☕', viewpoint: '🌄', museum: '🏛️', park: '🌳',
beach: '🏖️', gym: '🏋️', pool: '🏊',
};
// ─── Stop meta (icon + accent colour per type) ───────────────────────────────
type IconComponent = React.ComponentType<{ className?: string; size?: number | string; style?: React.CSSProperties }>;
function stopMeta(type: StopType): { icon: IconComponent; color: string } {
switch (type) {
case 'origin': return { icon: HomeIcon, color: '#9ca3af' };
case 'destination': return { icon: Flag, color: 'var(--gd-red)' };
case 'tunnel': return { icon: Route, color: '#a78bfa' };
case 'ferry':
case 'crossing': return { icon: Ship, color: '#60a5fa' };
case 'supercharger':
case 'destination-charger': return { icon: Zap, color: '#4ade80' };
case 'hotel': return { icon: Bed, color: '#60a5fa' };
case 'restaurant':
case 'cafe': return { icon: Utensils, color: '#fbbf24' };
case 'attraction':
case 'viewpoint': return { icon: Camera, color: '#c084fc' };
default: return { icon: MapPin, color: '#9ca3af' };
}
}
// ─── Geocoding (Nominatim) ───────────────────────────────────────────────────
const geocodeCache = new Map<string, { lat: number; lng: number }>();
async function geocodeLocation(query: string): Promise<{ lat: number; lng: number } | null> {
const cacheKey = query.toLowerCase().trim();
if (geocodeCache.has(cacheKey)) return geocodeCache.get(cacheKey)!;
try {
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`;
const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } });
const data = await res.json();
if (data && data.length > 0) {
const result = { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
geocodeCache.set(cacheKey, result);
await new Promise(r => setTimeout(r, 1100));
return result;
}
} catch {
console.warn('[TeslaTrip] Geocoding failed for', query);
}
return null;
}
// Reverse geocode lat/lng → human-readable place name (Nominatim).
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=14`;
const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } });
const data = await res.json();
if (!data) return null;
const a = data.address || {};
// Build something like "Milton Keynes, MK7 8PJ" — town + postcode if available.
const town = a.city || a.town || a.village || a.hamlet || a.suburb || a.county || '';
const postcode = a.postcode || '';
const country = a.country_code ? a.country_code.toUpperCase() : '';
const parts = [town, postcode, country].filter(Boolean);
return parts.join(', ') || data.display_name || null;
} catch {
console.warn('[TeslaTrip] Reverse geocoding failed', lat, lng);
return null;
}
}
// Browser geolocation wrapped in a promise with a short timeout — Tesla's
// in-car browser sometimes hangs forever waiting for a fix.
async function getBrowserLocation(timeoutMs = 8000): Promise<GeolocationCoordinates | null> {
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
return new Promise<GeolocationCoordinates | null>((resolve) => {
let settled = false;
const timer = setTimeout(() => { if (!settled) { settled = true; resolve(null); } }, timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => { if (!settled) { settled = true; clearTimeout(timer); resolve(pos.coords); } },
() => { if (!settled) { settled = true; clearTimeout(timer); resolve(null); } },
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 30_000 },
);
});
}
// ─── Itinerary normalization (preserves all enrichment fields) ───────────────
async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
const normalizedDays: any[] = [];
for (const day of raw.days) {
if (!day) continue;
let rawStops: any[] = [];
if (Array.isArray(day.stops)) rawStops = day.stops;
else if (Array.isArray(day.chargeStops)) rawStops = day.chargeStops;
else if (Array.isArray(raw.pointsOfInterest)) rawStops = raw.pointsOfInterest;
const validStops: any[] = [];
for (const s of rawStops) {
if (!s) continue;
const name = s.name || s.location;
if (typeof name !== 'string') continue;
let lat = typeof s.lat === 'number' ? s.lat : null;
let lng = typeof s.lng === 'number' ? s.lng : null;
if ((lat === null || lng === null) && name) {
const geo = await geocodeLocation(name);
if (geo) { lat = geo.lat; lng = geo.lng; }
}
const rawAlts = Array.isArray(s.alternatives) ? s.alternatives : [];
const cleanAlts: AlternativeStop[] = rawAlts
.map((a: any): AlternativeStop | null => {
if (!a || typeof a.name !== 'string') return null;
if (typeof a.lat !== 'number' || typeof a.lng !== 'number') return null;
return {
id: a.id || `alt-${Date.now()}-${Math.random()}`,
name: a.name,
type: STOP_TYPES.includes(a.type) ? a.type : 'custom',
lat: a.lat,
lng: a.lng,
description: typeof a.description === 'string' ? a.description : undefined,
combo: a.combo ?? null,
amenities: Array.isArray(a.amenities) ? a.amenities.filter((x: unknown) => typeof x === 'string') : undefined,
cuisine: typeof a.cuisine === 'string' ? a.cuisine : null,
priceLevel: typeof a.priceLevel === 'number' ? a.priceLevel : undefined,
chargeMinutes: typeof a.chargeMinutes === 'number' ? a.chargeMinutes : undefined,
durationMin: typeof a.durationMin === 'number' ? a.durationMin : undefined,
deltaKm: typeof a.deltaKm === 'number' ? a.deltaKm : undefined,
deltaMin: typeof a.deltaMin === 'number' ? a.deltaMin : undefined,
reason: typeof a.reason === 'string' ? a.reason : undefined,
};
})
.filter((a: AlternativeStop | null): a is AlternativeStop => a !== null);
const cleanNearby: NearbyPlace[] = Array.isArray(s.nearby)
? s.nearby.filter((n: any) => n && typeof n.name === 'string').map((n: any) => ({
category: ['food', 'do', 'see', 'shop', 'rest'].includes(n.category) ? n.category : 'food',
icon: typeof n.icon === 'string' ? n.icon : 'coffee',
name: n.name,
detail: typeof n.detail === 'string' ? n.detail : '',
}))
: [];
const cleanChargers: ChargerOption[] = Array.isArray(s.chargerOptions)
? s.chargerOptions
.filter((c: any) => c && typeof c.name === 'string')
.map((c: any): ChargerOption => ({
id: c.id || `charger-${Date.now()}-${Math.random()}`,
name: c.name,
network: typeof c.network === 'string' ? c.network : undefined,
stalls: typeof c.stalls === 'number' ? c.stalls : 0,
kw: typeof c.kw === 'number' ? c.kw : 0,
pricePerKwh: typeof c.pricePerKwh === 'number' ? c.pricePerKwh : 0,
detourMin: typeof c.detourMin === 'number' ? c.detourMin : 0,
isCurrent: c.isCurrent === true,
badge: typeof c.badge === 'string' ? c.badge : null,
}))
: [];
const cleanCrossings: CrossingOption[] = Array.isArray(s.crossingOptions)
? s.crossingOptions
.filter((c: any) => c && typeof c.operator === 'string')
.map((c: any): CrossingOption => ({
id: c.id || `crossing-${Date.now()}-${Math.random()}`,
operator: c.operator,
mode: c.mode === 'tunnel' ? 'tunnel' : 'ferry',
fromPort: typeof c.fromPort === 'string' ? c.fromPort : '',
toPort: typeof c.toPort === 'string' ? c.toPort : '',
durationMin: typeof c.durationMin === 'number' ? c.durationMin : 0,
priceEur: typeof c.priceEur === 'number' ? c.priceEur : 0,
frequency: typeof c.frequency === 'string' ? c.frequency : undefined,
pros: Array.isArray(c.pros) ? c.pros.filter((p: unknown) => typeof p === 'string') : undefined,
cons: Array.isArray(c.cons) ? c.cons.filter((p: unknown) => typeof p === 'string') : undefined,
badge: typeof c.badge === 'string' ? c.badge as CrossingOption['badge'] : null,
detourMin: typeof c.detourMin === 'number' ? c.detourMin : undefined,
detourKm: typeof c.detourKm === 'number' ? c.detourKm : undefined,
isCurrent: c.isCurrent === true,
bookingUrl: typeof c.bookingUrl === 'string' ? c.bookingUrl : undefined,
}))
: [];
const shared = {
estArrivalBattery: s.estArrivalBattery,
chargeMinutes: s.chargeMinutes,
durationMin: s.durationMin,
combo: s.combo ?? null,
description: typeof s.description === 'string' ? s.description : undefined,
amenities: Array.isArray(s.amenities) ? s.amenities.filter((a: unknown) => typeof a === 'string') : undefined,
cuisine: typeof s.cuisine === 'string' ? s.cuisine : null,
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
notes: s.notes,
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
nearby: cleanNearby.length > 0 ? cleanNearby : undefined,
chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined,
crossingOptions: cleanCrossings.length > 0 ? cleanCrossings : undefined,
};
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
validStops.push({
id: s.id || `stop-${Date.now()}-${Math.random()}`,
name, type: resolvedType,
lat: lat ?? null, lng: lng ?? null,
day: day.day || 1,
order: s.order || validStops.length + 1,
...shared,
});
}
if (validStops.length > 0) {
normalizedDays.push({
day: day.day || normalizedDays.length + 1,
title: typeof day.title === 'string' ? day.title : undefined,
stops: validStops.sort((a, b) => a.order - b.order),
});
}
}
const sortedDays = normalizedDays.sort((a, b) => a.day - b.day);
const allStops = sortedDays.flatMap(d => d.stops);
return {
days: sortedDays,
summary: {
totalDistanceKm: raw.summary?.totalDistanceKm ?? 0,
estDriveHours: raw.summary?.estDriveHours ?? 0,
estChargeHours: raw.summary?.estChargeHours ?? 0,
superchargers: allStops.filter(s => s.type === 'supercharger' || s.type === 'destination-charger').length,
hotels: allStops.filter(s => s.type === 'hotel').length,
highlights: Array.isArray(raw.summary?.highlights)
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
: [],
},
needsTravelDates: raw.needsTravelDates === true,
};
}
// Fast synchronous normalizer used for partial stream events — skips geocoding
// (Grok almost always provides lat/lng inline). Stops missing coords are dropped.
function normalizePartialItinerary(raw: any): Itinerary {
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
const normalizedDays: Itinerary['days'] = [];
for (const day of raw.days) {
if (!day) continue;
const rawStops: any[] = Array.isArray(day.stops) ? day.stops : [];
const validStops: Stop[] = [];
for (const s of rawStops) {
if (!s || typeof s.name !== 'string') continue;
if (typeof s.lat !== 'number' || typeof s.lng !== 'number') continue;
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
validStops.push({
id: s.id || `stop-${Date.now()}-${Math.random()}`,
name: s.name,
type: resolvedType,
lat: s.lat,
lng: s.lng,
day: day.day || 1,
order: s.order || validStops.length + 1,
estArrivalBattery: typeof s.estArrivalBattery === 'number' ? s.estArrivalBattery : undefined,
chargeMinutes: typeof s.chargeMinutes === 'number' ? s.chargeMinutes : undefined,
durationMin: typeof s.durationMin === 'number' ? s.durationMin : undefined,
combo: s.combo ?? null,
description: typeof s.description === 'string' ? s.description : undefined,
amenities: Array.isArray(s.amenities) ? s.amenities.filter((a: unknown) => typeof a === 'string') : undefined,
cuisine: typeof s.cuisine === 'string' ? s.cuisine : null,
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
notes: typeof s.notes === 'string' ? s.notes : undefined,
alternatives: undefined, // skip during partial — final event populates
nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined,
chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined,
crossingOptions: Array.isArray(s.crossingOptions) ? s.crossingOptions : undefined,
});
}
if (validStops.length > 0) {
normalizedDays.push({
day: day.day || normalizedDays.length + 1,
title: typeof day.title === 'string' ? day.title : undefined,
stops: validStops.sort((a, b) => a.order - b.order),
});
}
}
const sortedDays = normalizedDays.sort((a, b) => a.day - b.day);
const allStops = sortedDays.flatMap(d => d.stops);
return {
days: sortedDays,
summary: {
totalDistanceKm: raw.summary?.totalDistanceKm ?? 0,
estDriveHours: raw.summary?.estDriveHours ?? 0,
estChargeHours: raw.summary?.estChargeHours ?? 0,
superchargers: allStops.filter(s => s.type === 'supercharger' || s.type === 'destination-charger').length,
hotels: allStops.filter(s => s.type === 'hotel').length,
highlights: Array.isArray(raw.summary?.highlights)
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
: [],
},
needsTravelDates: raw.needsTravelDates === true,
};
}
function normalizeVariants(raw: any): RouteVariant[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((v: any) => v && typeof v.id === 'string')
.map((v: any): RouteVariant => ({
id: v.id === 'scenic' || v.id === 'cheap' ? v.id : 'fast',
label: typeof v.label === 'string' ? v.label : v.id,
tone: v.tone === 'green' || v.tone === 'blue' ? v.tone : 'primary',
distanceKm: typeof v.distanceKm === 'number' ? v.distanceKm : 0,
driveHours: typeof v.driveHours === 'number' ? v.driveHours : 0,
chargeHours: typeof v.chargeHours === 'number' ? v.chargeHours : 0,
costEur: typeof v.costEur === 'number' ? v.costEur : 0,
highlight: ['drive', 'cost', 'pretty'].includes(v.highlight) ? v.highlight : undefined,
pros: Array.isArray(v.pros) ? v.pros.filter((p: unknown) => typeof p === 'string') : [],
}));
}
// ─── Routing helpers ─────────────────────────────────────────────────────────
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371;
const toRad = (x: number) => (x * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
async function getRoadLeg(from: Stop, to: Stop): Promise<Leg> {
try {
const url = `https://router.project-osrm.org/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
const res = await fetch(url);
const data = await res.json();
const route = data.routes?.[0];
if (route) {
return {
geometry: route.geometry.coordinates.map((c: number[]) => [c[1], c[0]]),
distanceKm: route.distance / 1000,
durationMin: route.duration / 60,
fromId: from.id, toId: to.id,
};
}
} catch {
console.warn('[TeslaTrip] OSRM routing failed, falling back to straight line');
}
const dist = haversineKm(from, to);
return {
geometry: [[from.lat, from.lng], [to.lat, to.lng]],
distanceKm: dist,
durationMin: (dist / 80) * 60,
fromId: from.id, toId: to.id,
};
}
function formatDuration(min: number | null): string {
if (min == null || !Number.isFinite(min)) return '';
const total = Math.round(min);
if (total < 60) return `${total}m`;
const h = Math.floor(total / 60);
const m = total % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function formatKm(km: number | null): string {
if (km == null || !Number.isFinite(km)) return '';
return `${Math.round(km).toLocaleString()} km`;
}
function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
const rounded = Math.round(value);
if (rounded === 0) return `±0 ${unit}`;
return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`;
}
function formatDateShort(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
}
function formatDateRange(outbound: string | null | undefined, ret: string | null | undefined): string {
if (!outbound) return 'Add dates';
if (!ret) return `${formatDateShort(outbound)} · one-way`;
return `${formatDateShort(outbound)}${formatDateShort(ret)}`;
}
function nightsBetween(outbound: string | null | undefined, ret: string | null | undefined): number | null {
if (!outbound || !ret) return null;
const a = new Date(outbound);
const b = new Date(ret);
if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return null;
const diff = Math.round((b.getTime() - a.getTime()) / 86400000);
return diff > 0 ? diff : null;
}
// ─── Error Boundary ──────────────────────────────────────────────────────────
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
constructor(props: any) { super(props); this.state = { hasError: false }; }
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error: Error) { console.error('[TeslaTrip] ErrorBoundary caught:', error); }
render() {
if (this.state.hasError) {
return (
<div className="h-full flex items-center justify-center bg-red-950/30 text-red-400 p-6">
<div className="text-center">
<AlertTriangle className="w-8 h-8 mx-auto mb-3" />
<div>Something went wrong rendering the planner.</div>
</div>
</div>
);
}
return this.props.children;
}
}
// ─── Reusable bits ───────────────────────────────────────────────────────────
function ChipButton({ children, onClick, className = '' }: { children: React.ReactNode; onClick?: () => void; className?: string }) {
return (
<button
onClick={onClick}
className={`h-[38px] px-3 inline-flex items-center gap-1.5 rounded-[10px] border text-[12.5px] whitespace-nowrap transition ${className}`}
style={{
borderColor: 'var(--gd-border)',
background: 'var(--gd-panel)',
color: 'var(--gd-text)',
}}
>
{children}
</button>
);
}
function LegRow({ leg }: { leg: Leg | undefined }) {
if (!leg) return null;
return (
<div className="flex items-center gap-3 pl-[52px] pr-3 py-2 text-[11px] num" style={{ color: 'var(--gd-text-3)' }}>
<Route className="w-3 h-3" />
<span>{formatKm(leg.distanceKm)}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span>{formatDuration(leg.durationMin)} drive</span>
</div>
);
}
// ─── Skeleton shimmer ────────────────────────────────────────────────────────
function SkeletonRow({ widthPct = 100 }: { widthPct?: number }) {
return (
<div
className="h-2.5 rounded animate-pulse"
style={{ width: `${widthPct}%`, background: 'linear-gradient(90deg, var(--gd-border) 0%, var(--gd-border-2) 50%, var(--gd-border) 100%)' }}
/>
);
}
// ─── Variant strip ───────────────────────────────────────────────────────────
const VARIANT_TONE: Record<RouteVariant['tone'], string> = {
primary: 'var(--gd-red)',
green: 'var(--gd-green)',
blue: 'var(--gd-blue)',
};
const VARIANT_ICON: Record<RouteVariant['id'], IconComponent> = {
fast: Gauge,
scenic: TreePine,
cheap: Euro,
};
function VariantStrip({
variants, selected, onSelect, switching, cachedIds, showCompare, onToggleCompare,
}: {
variants: RouteVariant[];
selected: string;
onSelect: (id: RouteVariant['id']) => void;
switching: boolean;
cachedIds: string[];
showCompare: boolean;
onToggleCompare: () => void;
}) {
if (variants.length === 0) return null;
const compareEligible = cachedIds.length >= 2;
return (
<div
className="px-6 py-3.5 flex items-center gap-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--gd-border)', background: 'var(--gd-bg)' }}
>
<div className="grid grid-cols-3 gap-3 flex-1">
{variants.map(v => {
const isSel = v.id === selected;
const tone = VARIANT_TONE[v.tone];
const Icon = VARIANT_ICON[v.id];
return (
<button
key={v.id}
onClick={() => onSelect(v.id)}
disabled={switching}
className="relative text-left p-3.5 rounded-[14px] overflow-hidden transition disabled:opacity-50 disabled:cursor-wait"
style={{
background: isSel ? `color-mix(in srgb, ${tone} 6%, transparent)` : 'var(--gd-panel)',
border: `1px solid ${isSel ? tone : 'var(--gd-border)'}`,
cursor: switching ? 'wait' : 'pointer',
}}
>
{isSel && (
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: tone }} />
)}
<div className="flex items-center gap-2 mb-3">
<div className="text-[14px] font-semibold" style={{ letterSpacing: '-0.01em' }}>{v.label}</div>
{isSel && (
<span
className="text-[9px] font-semibold tracking-wider px-1.5 py-0.5 rounded-full"
style={{ border: `1px solid ${tone}`, color: tone }}
>
SELECTED
</span>
)}
<div className="flex-1" />
<Icon size={16} style={{ color: tone }} />
</div>
<div className="flex gap-4 mb-3">
<VStat label="Drive" value={formatDuration(v.driveHours * 60)} highlight={v.highlight === 'drive'} tone={tone} />
<VStat label="Charge" value={formatDuration(v.chargeHours * 60)} />
<VStat label="Cost" value={`${Math.round(v.costEur)}`} highlight={v.highlight === 'cost'} tone={tone} />
<VStat label="Distance" value={formatKm(v.distanceKm)} />
</div>
<div className="flex flex-wrap gap-1">
{v.pros.map(p => (
<span
key={p}
className="text-[10.5px] px-2 py-0.5 rounded-full"
style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--gd-text-2)' }}
>
{p}
</span>
))}
</div>
</button>
);
})}
</div>
<button
onClick={onToggleCompare}
disabled={!compareEligible}
title={compareEligible ? 'Show every cached variant on the map' : 'Visit at least two variants to compare them on the map'}
className="h-[60px] px-3.5 rounded-[14px] flex flex-col justify-center items-center gap-1 transition flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: showCompare ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${showCompare ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
color: showCompare ? 'var(--gd-red)' : 'var(--gd-text-2)',
}}
>
<ArrowLeftRight className="w-3.5 h-3.5" />
<span className="text-[10.5px] font-medium whitespace-nowrap">
Compare {cachedIds.length > 0 ? `(${cachedIds.length})` : ''}
</span>
</button>
</div>
);
}
function VStat({ label, value, highlight, tone }: { label: string; value: string; highlight?: boolean; tone?: string }) {
return (
<div className="min-w-0">
<div className="text-[9.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>{label}</div>
<div
className="text-[15px] font-medium num mt-px whitespace-nowrap"
style={{ color: highlight ? tone : 'var(--gd-text)' }}
>
{value}
</div>
</div>
);
}
// ─── Day header (sticky) ─────────────────────────────────────────────────────
function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: {
dayNumber: number;
title?: string;
distanceKm: number;
driveMin: number;
chargeMin: number;
dateLabel?: string;
}) {
return (
<div
className="sticky top-0 z-[2] pt-4 pb-2.5"
style={{ background: 'var(--gd-bg-2)', borderBottom: '1px solid var(--gd-border)' }}
>
<div className="flex items-baseline gap-2">
<div className="text-[11px] font-bold uppercase tracking-[0.08em]" style={{ color: 'var(--gd-red)' }}>
DAY {dayNumber}
</div>
{dateLabel && <div className="text-[11px]" style={{ color: 'var(--gd-text-3)' }}>· {dateLabel}</div>}
</div>
{title && <div className="text-[12.5px] mt-0.5" style={{ color: 'var(--gd-text-2)' }}>{title}</div>}
<div className="text-[11px] num mt-1" style={{ color: 'var(--gd-text-3)' }}>
{formatKm(distanceKm)} · {formatDuration(driveMin)} drive · {formatDuration(chargeMin)} charging
</div>
</div>
);
}
// ─── Alternative row (used inside expanded stop) ─────────────────────────────
function AlternativeRow({ alt, onSwap }: { alt: AlternativeStop; onSwap: () => void }) {
const km = formatDelta(alt.deltaKm, 'km');
const min = formatDelta(alt.deltaMin, 'min');
const isFaster = typeof alt.deltaMin === 'number' && alt.deltaMin < 0;
const isLonger = typeof alt.deltaMin === 'number' && alt.deltaMin > 0;
const deltaColor = isLonger ? 'var(--gd-amber)' : isFaster ? 'var(--gd-green)' : 'var(--gd-text-3)';
return (
<button
onClick={(e) => { e.stopPropagation(); onSwap(); }}
className="w-full text-left rounded-lg p-2.5 border transition hover:brightness-110"
style={{ background: 'var(--gd-bg)', borderColor: 'var(--gd-border)' }}
>
<div className="flex justify-between items-start gap-2">
<div className="min-w-0 flex-1">
<div className="text-[12px] font-medium truncate" style={{ color: 'var(--gd-text)' }}>{alt.name}</div>
{alt.combo && (
<div
className="inline-block mt-0.5 px-1.5 py-px rounded text-[9px] font-semibold uppercase tracking-wider"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}
>
{alt.combo}
</div>
)}
{alt.reason && (
<div className="text-[10.5px] mt-0.5 leading-snug" style={{ color: 'var(--gd-text-3)' }}>{alt.reason}</div>
)}
</div>
<div className="flex flex-col items-end text-[10.5px] num flex-shrink-0" style={{ color: deltaColor }}>
{km && <span>{km}</span>}
{min && <span>{min}</span>}
</div>
</div>
</button>
);
}
// ─── Expanded stop body (stats, charger swap, while-here, alts, actions) ─────
const NEARBY_TABS: { id: 'all' | NearbyPlace['category']; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'food', label: 'Food' },
{ id: 'do', label: 'Do' },
{ id: 'see', label: 'See' },
];
function NearbyGrid({ items }: { items: NearbyPlace[] }) {
return (
<div className="grid grid-cols-2 gap-1.5">
{items.map((n, i) => (
<div
key={i}
className="p-2 rounded-lg flex items-center gap-2"
style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}
>
<div
className="w-6 h-6 rounded grid place-items-center flex-shrink-0 text-[13px] leading-none"
style={{ background: 'var(--gd-panel-2)' }}
>
{AMENITY_ICONS[n.icon] || '•'}
</div>
<div className="min-w-0 flex-1">
<div className="text-[11.5px] font-medium truncate">{n.name}</div>
<div className="text-[10px] truncate" style={{ color: 'var(--gd-text-3)' }}>{n.detail}</div>
</div>
</div>
))}
</div>
);
}
function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPick: (c: ChargerOption) => void }) {
const [open, setOpen] = React.useState(false);
if (options.length === 0) return null;
const current = options.find(o => o.isCurrent) || options[0];
const others = options.filter(o => o.id !== current.id);
return (
<div>
<button
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
className="w-full p-2.5 rounded-lg flex items-center gap-2.5 text-left transition"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
>
<div
className="w-7 h-7 rounded-md grid place-items-center flex-shrink-0"
style={{ background: 'rgba(74,222,128,0.18)' }}
>
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--gd-green)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium truncate">{current.name}</div>
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{current.stalls} stalls · {current.kw} kW · {current.pricePerKwh.toFixed(2)}/kWh
{current.network && current.network !== 'Tesla' ? ` · ${current.network}` : ''}
</div>
</div>
<div className="text-[11px] font-medium flex items-center gap-1 flex-shrink-0" style={{ color: 'var(--gd-red)' }}>
{others.length > 0 && `${others.length} alts`}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</div>
</button>
{open && others.length > 0 && (
<div className="mt-1.5 p-1.5 rounded-lg space-y-0.5" style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}>
{others.map(o => (
<div
key={o.id}
onClick={(e) => e.stopPropagation()}
className="p-2.5 rounded-md flex items-center gap-2.5 transition hover:bg-white/[0.04]"
>
<div
className="w-6 h-6 rounded grid place-items-center flex-shrink-0"
style={{ background: 'rgba(255,255,255,0.04)' }}
>
<Zap className="w-3 h-3" style={{ color: 'var(--gd-text-2)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium flex items-center gap-1.5">
<span className="truncate">{o.name}</span>
{o.badge && (
<span
className="text-[9px] px-1.5 py-px rounded font-semibold whitespace-nowrap"
style={{ color: 'var(--gd-amber)', border: '1px solid rgba(251,191,36,0.4)' }}
>
{o.badge}
</span>
)}
</div>
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{o.stalls} stalls · {o.kw} kW · {o.pricePerKwh.toFixed(2)}/kWh
{o.detourMin > 0 ? ` · +${o.detourMin} min` : ''}
{o.network && o.network !== 'Tesla' ? ` · ${o.network}` : ''}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); onPick(o); }}
className="h-6 px-2.5 rounded-md text-[10.5px] flex-shrink-0"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
Use
</button>
</div>
))}
</div>
)}
</div>
);
}
function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing, onSendToNav, canSendToNav }: {
stop: Stop;
onSwap: (alt: AlternativeStop) => void;
onRemove: () => void;
onCustomise: () => void;
onPickCrossing?: (c: CrossingOption) => void;
onSendToNav?: () => void;
canSendToNav?: boolean;
}) {
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isCrossing = stop.type === 'crossing' || stop.type === 'tunnel' || stop.type === 'ferry';
const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null;
const charge = typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 ? stop.chargeMinutes : null;
const leave = arrive != null && charge != null ? Math.min(100, arrive + Math.round(charge * 1.1)) : null;
const cost = arrive != null && charge != null ? (Math.max(20, 80 - arrive) * 0.35).toFixed(2) : null;
const amenities = (stop.amenities || []).slice(0, 8);
const alts = stop.alternatives || [];
const chargers = stop.chargerOptions || [];
const crossings = stop.crossingOptions || [];
const nearby = stop.nearby || [];
const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all');
const filteredNearby = nearbyTab === 'all' ? nearby : nearby.filter(n => n.category === nearbyTab);
return (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
{stop.description && (
<div className="text-[12px] leading-[1.55] mb-3" style={{ color: 'var(--gd-text-2)' }}>{stop.description}</div>
)}
{isCharge && (arrive != null || charge != null) && (
<div className="grid grid-cols-4 gap-1.5 mb-3.5">
<ExpStat label="Charge" value={charge != null ? `${charge}m` : ''} tone="green" />
<ExpStat label="Arrive" value={arrive != null ? `${arrive}%` : ''} tone={arrive != null && arrive < 30 ? 'amber' : 'text'} />
<ExpStat label="Leave" value={leave != null ? `${leave}%` : ''} tone="green" />
<ExpStat label="Cost" value={cost != null ? `${cost}` : ''} />
</div>
)}
{isCharge && chargers.length > 0 && (
<div className="mb-3.5">
<SectionLabel>Charger</SectionLabel>
<ChargerSwapBlock
options={chargers}
onPick={(c) => toast.success(`Picked ${c.name}`, { description: `${c.kw} kW · €${c.pricePerKwh.toFixed(2)}/kWh` })}
/>
</div>
)}
{isCrossing && crossings.length > 0 && (
<div className="mb-3.5">
<SectionLabel>Crossing</SectionLabel>
<CrossingSwapBlock
options={crossings}
onPick={(c) => onPickCrossing?.(c)}
/>
</div>
)}
{nearby.length > 0 && (
<div className="mb-3">
<div className="flex items-center justify-between mb-2">
<SectionLabel>While here</SectionLabel>
<div className="flex gap-1">
{NEARBY_TABS.map(t => {
const isSel = nearbyTab === t.id;
const exists = t.id === 'all' || nearby.some(n => n.category === t.id);
if (!exists) return null;
return (
<button
key={t.id}
onClick={(e) => { e.stopPropagation(); setNearbyTab(t.id); }}
className="h-5.5 px-2 rounded-md text-[10.5px] transition"
style={{
background: isSel ? 'var(--gd-red-soft)' : 'transparent',
color: isSel ? 'var(--gd-red)' : 'var(--gd-text-3)',
fontWeight: isSel ? 600 : 400,
}}
>
{t.label}
</button>
);
})}
</div>
</div>
{filteredNearby.length > 0 ? (
<NearbyGrid items={filteredNearby.slice(0, 6)} />
) : (
<div className="text-[11px] italic px-2 py-2" style={{ color: 'var(--gd-text-3)' }}>
Nothing in this category nearby.
</div>
)}
</div>
)}
{amenities.length > 0 && (
<div className="mb-3">
<SectionLabel>Amenities</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{amenities.map(a => (
<div
key={a}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px]"
style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
<span className="text-[13px] leading-none">{AMENITY_ICONS[a] || '•'}</span>
<span className="capitalize">{a.replace(/-/g, ' ')}</span>
</div>
))}
</div>
</div>
)}
{alts.length > 0 && (
<div className="mb-3">
<SectionLabel>{alts.length} location alternative{alts.length === 1 ? '' : 's'}</SectionLabel>
<div className="space-y-1.5">
{alts.map(alt => (
<AlternativeRow key={alt.id} alt={alt} onSwap={() => onSwap(alt)} />
))}
</div>
</div>
)}
{stop.notes && (
<div
className="text-[11px] italic mt-2 mb-3 px-3 py-2 rounded"
style={{ background: 'var(--gd-bg)', color: 'var(--gd-text-3)' }}
>
{stop.notes}
</div>
)}
<div className="flex gap-1.5 mt-3">
{canSendToNav && (
<button
onClick={(e) => { e.stopPropagation(); onSendToNav?.(); }}
className="flex-1 h-8 inline-flex items-center justify-center gap-1.5 rounded-lg text-[11.5px] transition"
style={{ background: 'var(--gd-red)', color: '#fff', border: '1px solid var(--gd-red)' }}
>
<Navigation className="w-3 h-3" /> Send to Tesla nav
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); onCustomise(); }}
className="flex-1 h-8 inline-flex items-center justify-center gap-1.5 rounded-lg text-[11.5px] border transition"
style={{ background: 'var(--gd-panel-2)', borderColor: 'var(--gd-border)', color: 'var(--gd-text)' }}
>
<Settings2 className="w-3 h-3" /> Customise
</button>
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="h-8 px-3 inline-flex items-center gap-1.5 rounded-lg text-[11.5px] transition"
style={{ background: 'transparent', border: '1px solid rgba(227,25,55,0.3)', color: 'var(--gd-red)' }}
>
<Trash2 className="w-3 h-3" /> Remove
</button>
</div>
</div>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="text-[10px] font-semibold uppercase tracking-[0.07em] mb-2" style={{ color: 'var(--gd-text-3)' }}>
{children}
</div>
);
}
function ExpStat({ label, value, tone = 'text' }: { label: string; value: string; tone?: 'green' | 'amber' | 'text' }) {
const color = tone === 'green' ? 'var(--gd-green)' : tone === 'amber' ? 'var(--gd-amber)' : 'var(--gd-text)';
return (
<div className="px-2.5 py-2 rounded-lg" style={{ background: 'var(--gd-bg)' }}>
<div className="text-[9.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>{label}</div>
<div className="text-[14px] font-medium num mt-px" style={{ color }}>{value}</div>
</div>
);
}
// ─── Night transition block (between days) ───────────────────────────────────
function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHotelOptions: () => void }) {
const isHotel = lastStop.type === 'hotel';
const label = isHotel ? lastStop.name : 'Sleep in car at services';
const detail = isHotel
? `Overnight · ${lastStop.cuisine || 'destination charging'}`
: 'Safe overnight at last Supercharger · 24h facilities';
return (
<div
className="my-3.5 p-3.5 rounded-[12px] flex items-center gap-3"
style={{
background: 'rgba(96,165,250,0.07)',
border: '1px dashed rgba(96,165,250,0.4)',
}}
>
<div
className="w-[38px] h-[38px] rounded-[9px] grid place-items-center flex-shrink-0"
style={{ background: 'rgba(96,165,250,0.16)' }}
>
<Bed className="w-4 h-4" style={{ color: 'var(--gd-blue)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{label}</div>
<div className="text-[11.5px]" style={{ color: 'var(--gd-text-3)' }}>{detail}</div>
</div>
<button
onClick={onOpenHotelOptions}
className="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-lg text-[11.5px] whitespace-nowrap"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
>
<ArrowLeftRight className="w-3 h-3" /> Hotel options
</button>
</div>
);
}
// ─── Stop card (icon-led) ────────────────────────────────────────────────────
function StopCard({
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, onPickCrossing,
onMoveUp, onMoveDown, canMoveUp, canMoveDown, onSendToNav, canSendToNav,
onDragStart, onDragOver, onDrop, onDragEnd,
}: {
stop: Stop;
active: boolean;
hover: boolean;
dragging: boolean;
onSelect: () => void;
onHover: (h: boolean) => void;
onSwap: (alt: AlternativeStop) => void;
onRemove: () => void;
onCustomise: () => void;
onPickCrossing?: (c: CrossingOption) => void;
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
onSendToNav?: () => void;
canSendToNav?: boolean;
onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
onDragEnd: () => void;
}) {
const meta = stopMeta(stop.type);
const Icon = meta.icon;
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isSleep = stop.type === 'hotel';
const isCrossingStop = stop.type === 'tunnel' || stop.type === 'ferry' || stop.type === 'crossing';
const currentCrossing = isCrossingStop ? (stop.crossingOptions?.find(o => o.isCurrent) || stop.crossingOptions?.[0]) : undefined;
return (
<div
onClick={onSelect}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className="rounded-[12px] p-3.5 mb-2 cursor-pointer transition-all"
style={{
background: active ? 'var(--gd-panel-2)' : hover ? 'rgba(255,255,255,0.025)' : 'var(--gd-panel)',
border: `1px solid ${active ? 'var(--gd-red-line)' : hover ? 'var(--gd-border-2)' : 'var(--gd-border)'}`,
boxShadow: active ? '0 0 0 1px var(--gd-red-soft), 0 6px 20px rgba(0,0,0,0.25)' : 'none',
opacity: dragging ? 0.5 : 1,
}}
>
<div className="flex items-start gap-3">
<div className="flex flex-col items-center gap-1 flex-shrink-0">
<div
className="w-10 h-10 rounded-[10px] grid place-items-center"
style={{ background: `${meta.color}22` }}
>
<Icon size={18} className="" />
</div>
<div className="flex flex-col gap-0.5">
<button
onClick={(e) => { e.stopPropagation(); onMoveUp(); }}
disabled={!canMoveUp}
title="Move up"
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<ArrowUp className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onMoveDown(); }}
disabled={!canMoveDown}
title="Move down"
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<ArrowDown className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</button>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium text-[14px] truncate" style={{ letterSpacing: '-0.005em' }}>{stop.name}</div>
{isCharge && typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 && (
<div className="text-[11px] num flex-shrink-0 whitespace-nowrap">
<span style={{ color: 'var(--gd-green)', fontWeight: 600 }}>{stop.chargeMinutes}m</span>
{typeof stop.estArrivalBattery === 'number' && (
<>
<span className="mx-1" style={{ color: 'var(--gd-text-3)' }}>·</span>
<span style={{ color: 'var(--gd-text-2)' }}>arrive {stop.estArrivalBattery}%</span>
</>
)}
</div>
)}
{isSleep && (
<div className="text-[11px] num font-medium flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-blue)' }}>overnight</div>
)}
{isCrossingStop && currentCrossing && (
<div className="text-[11px] num flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-text-3)' }}>
{Math.round(currentCrossing.priceEur)} · {formatDuration(currentCrossing.durationMin)}
</div>
)}
</div>
{stop.combo && (
<div
className="inline-block mt-1 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}
>
{stop.combo}
</div>
)}
<div className="text-[11.5px] mt-1" style={{ color: 'var(--gd-text-3)' }}>
{stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : stop.type === 'tunnel' ? 'Eurotunnel · drive on/off' : stop.type === 'ferry' ? 'Ferry crossing' : stop.type === 'crossing' ? 'Sea crossing' : 'Stop')}
</div>
{!active && stop.description && (
<div className="text-[12px] mt-2 leading-snug line-clamp-2" style={{ color: 'var(--gd-text-2)' }}>
{stop.description}
</div>
)}
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} onPickCrossing={onPickCrossing} onSendToNav={onSendToNav} canSendToNav={canSendToNav} />}
</div>
</div>
</div>
);
}
// ─── Map controller: fit bounds when stops change ────────────────────────────
function MapAutoFit({ stops }: { stops: Stop[] }) {
const map = useMap();
React.useEffect(() => {
const valid = stops.filter(s => typeof s.lat === 'number' && typeof s.lng === 'number');
if (valid.length < 2) return;
const bounds = L.latLngBounds(valid.map(s => [s.lat, s.lng] as [number, number]));
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 8 });
}, [stops, map]);
return null;
}
function MapFlyTo({ stop }: { stop: Stop | null }) {
const map = useMap();
React.useEffect(() => {
if (!stop || typeof stop.lat !== 'number') return;
map.flyTo([stop.lat, stop.lng], Math.max(map.getZoom(), 7), { duration: 0.6 });
}, [stop, map]);
return null;
}
// ─── Pin marker (svg-style coloured dot via Leaflet divIcon) ─────────────────
function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon {
const size = active ? 28 : hover ? 22 : 18;
const dot = active ? 10 : hover ? 8 : 7;
return L.divIcon({
className: 'gd-pin',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `
<div style="width:${size}px;height:${size}px;display:grid;place-items:center;">
${active ? `<div style="position:absolute;width:${size}px;height:${size}px;border-radius:50%;background:${color}33;"></div>` : ''}
<div style="position:relative;width:${dot * 2}px;height:${dot * 2}px;border-radius:50%;background:#0a0a0c;border:2px solid ${color};box-shadow:0 1px 4px rgba(0,0,0,0.5);"></div>
</div>`,
});
}
// ─── Top bar ─────────────────────────────────────────────────────────────────
function TopBar({
origin, destination, onOriginChange, onDestinationChange, onODCommit,
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
travelDates, onOpenDates, onUseMyLocation,
teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, inCar,
}: {
origin: string; destination: string;
onOriginChange: (v: string) => void;
onDestinationChange: (v: string) => void;
onODCommit: () => void;
chatInput: string; setChatInput: (v: string) => void;
onChatSubmit: () => void;
chips: string[]; onRemoveChip: (i: number) => void;
vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void;
grokStatus: { label?: string };
onOpenGpx: () => void;
travelDates: TravelDates;
onOpenDates: (rect: DOMRect) => void;
onUseMyLocation: () => void;
teslaStatus: ReturnType<typeof useTesla>['status'];
teslaState: ReturnType<typeof useTesla>['state'];
onConnectTesla: () => void;
onDisconnectTesla: () => void;
inCar: boolean;
}) {
const hideVehicleChip = inCar && !!teslaStatus?.connected;
const hideGpxChip = inCar;
const [locating, setLocating] = React.useState(false);
const handleLocate = async () => {
setLocating(true);
try { await onUseMyLocation(); } finally { setLocating(false); }
};
const datesLabel = travelDates.outbound
? formatDateRange(travelDates.outbound, travelDates.return)
: 'Add dates';
const datesEmpty = !travelDates.outbound;
return (
<div
className="flex items-center gap-4 px-5 flex-shrink-0"
style={{ height: 62, background: 'var(--gd-bg)', borderBottom: '1px solid var(--gd-border)' }}
>
{/* Brand */}
<div className="flex items-center gap-2.5">
<div className="w-[30px] h-[30px] rounded-[9px] grid place-items-center" style={{ background: 'var(--gd-red)' }}>
<Zap className="w-4 h-4 text-white" />
</div>
<div className="flex items-center gap-1.5 whitespace-nowrap">
<div className="font-semibold text-[16px]" style={{ letterSpacing: '-0.02em' }}>Grok Drive</div>
<span
className="text-[9.5px] font-semibold tracking-wider px-1.5 py-0.5 rounded"
style={{ background: 'var(--gd-panel-2)', color: 'var(--gd-text-2)' }}
>
BETA
</span>
</div>
</div>
{/* Origin → Destination */}
<div
className="flex items-center rounded-[10px] overflow-hidden"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 380 }}
>
<div className="px-3.5 flex items-center gap-2 h-full flex-1" style={{ borderRight: '1px solid var(--gd-border)' }}>
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-text-2)' }} />
<input
value={origin}
onChange={(e) => onOriginChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }}
onBlur={onODCommit}
placeholder="From"
className="bg-transparent border-none outline-none text-[13px] w-full"
style={{ color: 'var(--gd-text)' }}
/>
<button
onClick={handleLocate}
disabled={locating}
title="Use my current location"
className="grid place-items-center rounded-md flex-shrink-0 disabled:opacity-40"
style={{
width: 26, height: 26,
background: 'var(--gd-panel-2)',
border: '1px solid var(--gd-border)',
color: 'var(--gd-text-2)',
}}
>
<Crosshair className={`w-3.5 h-3.5 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
<div className="px-3.5 flex items-center gap-2 h-full flex-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
<input
value={destination}
onChange={(e) => onDestinationChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }}
onBlur={onODCommit}
placeholder="To"
className="bg-transparent border-none outline-none text-[13px] w-full"
style={{ color: 'var(--gd-text)' }}
/>
</div>
</div>
<div className="flex-1" />
{/* Chat composer with chips */}
<div
className="flex items-center pl-3 rounded-[10px]"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 360 }}
>
<Sparkles className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
{chips.length > 0 && (
<div className="flex gap-1 ml-2">
{chips.map((c, i) => (
<div
key={i}
className="text-[11px] pl-2 pr-1 py-0.5 rounded-full inline-flex items-center gap-1 max-w-[140px]"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}
>
<span className="truncate">{c}</span>
<button
onClick={() => onRemoveChip(i)}
className="w-3.5 h-3.5 grid place-items-center rounded-full"
style={{ color: 'var(--gd-red)' }}
>
<X className="w-2.5 h-2.5" />
</button>
</div>
))}
</div>
)}
<input
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onChatSubmit()}
placeholder={chips.length ? 'add another...' : 'Refine — "avoid tolls", "lunch in Lyon"'}
className="flex-1 px-3 bg-transparent border-none outline-none text-[13px] min-w-[60px]"
style={{ color: 'var(--gd-text)' }}
/>
<button
onClick={onChatSubmit}
className="w-[30px] h-[30px] m-1 rounded-[7px] grid place-items-center"
style={{ background: 'var(--gd-red)' }}
>
<Send className="w-3 h-3 text-white" />
</button>
</div>
{/* Vehicle chip — opens trim panel. Hidden when in-car with Tesla connected (we already know the car). */}
{!hideVehicleChip && (
<button
onClick={(e) => onOpenVehiclePanel(e.currentTarget.getBoundingClientRect())}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<Gauge className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
<div className="flex flex-col items-start leading-[1.15]">
<div className="text-[12px] font-medium" style={{ letterSpacing: '-0.005em' }}>
{vehicle.name}{' '}
<span style={{ color: 'var(--gd-text-3)', fontWeight: 400 }}>{abbrevTrim(vehicle.trim)}</span>
</div>
<div className="text-[10px] num" style={{ color: 'var(--gd-text-3)' }}>{vehicle.rangeKm} km</div>
</div>
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
</button>
)}
<button
onClick={(e) => onOpenDates(e.currentTarget.getBoundingClientRect())}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{
background: datesEmpty ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${datesEmpty ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text)',
}}
>
<CalendarDays className="w-3.5 h-3.5" />
<div className="text-[12px] font-medium">{datesLabel}</div>
<ChevronDown className="w-3 h-3" style={{ color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text-3)' }} />
</button>
{teslaStatus?.available && (
teslaStatus.connected ? (
<button
onClick={onDisconnectTesla}
title="Disconnect Tesla"
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
<div className="flex flex-col items-start leading-[1.15]">
<div className="text-[11.5px] font-medium">
{teslaState?.battery != null ? `${teslaState.battery}%` : 'Connected'}
</div>
<div className="text-[9.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{teslaState?.rangeKm != null ? `${teslaState.rangeKm} km` : (teslaState?.asleep ? 'asleep' : '—')}
</div>
</div>
</button>
) : (
<button
onClick={onConnectTesla}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)', color: 'var(--gd-red)' }}
>
<Car className="w-3.5 h-3.5" />
<div className="text-[12px] font-medium">Connect Tesla</div>
</button>
)
)}
{!hideGpxChip && (
<ChipButton onClick={() => onOpenGpx()}>
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
Export
</ChipButton>
)}
{!inCar && (
<ChipButton onClick={() => toast('Shareable link copied')}>
<Share2 className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
Share
</ChipButton>
)}
{grokStatus.label && (
<div className="text-[10px] px-2 py-1 rounded" style={{ background: 'var(--gd-panel-2)', color: 'var(--gd-text-3)' }}>
{grokStatus.label}
</div>
)}
</div>
);
}
// ─── Main planner ────────────────────────────────────────────────────────────
export default function TeslaTripPlanner() {
const tesla = useTesla();
const inCar = React.useMemo(() => detectInCar().isInCar, []);
const teslaConnected = !!tesla.status?.connected;
const teslaInCar = inCar && teslaConnected;
// When Tesla is connected, auto-detect the vehicle model + trim from the
// Fleet API response so the planner uses the real range / kW instead of
// the default.
React.useEffect(() => {
if (!tesla.state) return;
const carType = tesla.state.carType || tesla.status?.carType;
const trimBadging = tesla.state.trimBadging || tesla.status?.trimBadging;
if (!carType) return;
const modelId = carType === 'modely' ? 'model-y'
: carType === 'model3' ? 'model-3'
: carType === 'models' ? 'model-s'
: carType === 'modelx' ? 'model-x'
: carType === 'cybertruck' ? 'cybertruck'
: null;
if (!modelId) return;
const model = TESLA_MODELS.find(m => m.id === modelId);
if (!model) return;
// Match trim badging (e.g. "lrawd" → Long Range AWD)
const trim = model.trims.find(t => {
const n = (trimBadging || '').toLowerCase();
const id = t.id.toLowerCase().replace(/-/g, '');
return n === id || n.includes(id) || id.includes(n);
}) || model.trims[0];
if (!trim) return;
setVehicle(prev => prev.modelId === model.id && prev.trimId === trim.id ? prev : {
modelId: model.id, trimId: trim.id, name: model.name, trim: trim.name,
rangeKm: trim.rangeKm, kw: trim.kw, sec0to60: trim.sec0to60, topKmh: trim.topKmh, badge: trim.badge,
});
}, [tesla.state?.carType, tesla.state?.trimBadging, tesla.status?.carType, tesla.status?.trimBadging]);
// When in-car AND Tesla connected: auto-fill the origin from the car's GPS
// once on mount, so the user doesn't have to type their starting point.
const autoOriginRef = React.useRef(false);
React.useEffect(() => {
if (!teslaInCar || autoOriginRef.current) return;
if (!tesla.state?.lat || !tesla.state?.lng) return;
autoOriginRef.current = true;
reverseGeocode(tesla.state.lat, tesla.state.lng).then(name => {
if (name) setOrigin(name);
});
}, [teslaInCar, tesla.state?.lat, tesla.state?.lng]);
// Surface a toast once after the OAuth round trip lands us back at /?tesla_connected=1
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('tesla_connected') === '1') {
toast.success('Tesla connected', { description: 'Live battery + GPS + send-to-nav enabled.' });
params.delete('tesla_connected');
const q = params.toString();
window.history.replaceState({}, '', window.location.pathname + (q ? `?${q}` : ''));
} else if (params.get('tesla_error')) {
toast.error('Tesla connect failed', { description: params.get('tesla_error') || undefined });
params.delete('tesla_error');
const q = params.toString();
window.history.replaceState({}, '', window.location.pathname + (q ? `?${q}` : ''));
}
}, []);
const [messages, setMessages] = useState<{ id: number; role: 'user' | 'assistant'; content: string }[]>([
{ id: 1, role: 'assistant', content: "Hello! I'm Grok Drive. Tell me where you want to go." },
]);
const [chatInput, setChatInput] = useState('');
const [chips, setChips] = useState<string[]>([]);
const [thinking, setThinking] = useState(false);
const [thinkingMessage, setThinkingMessage] = useState<string>('');
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState<Vehicle>(DEFAULT_VEHICLE);
const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false);
const [vehicleAnchor, setVehicleAnchor] = useState<DOMRect | null>(null);
const [grokStatus, setGrokStatus] = useState<{ label?: string }>({ label: 'Local Heavy' });
const [legs, setLegs] = useState<Leg[]>([]);
const [activeStopId, setActiveStopId] = useState<string | null>(null);
const [hoverStopId, setHoverStopId] = useState<string | null>(null);
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
const [ownerLoginOpen, setOwnerLoginOpen] = useState(false);
// After successful owner login, fire this pending action.
const ownerLoginThenRef = React.useRef<null | (() => void)>(null);
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
const [variantSwitching, setVariantSwitching] = useState(false);
const [variantCache, setVariantCache] = useState<Record<string, { itinerary: Itinerary; legs: Leg[] }>>({});
const [showCompare, setShowCompare] = useState(false);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [modal, setModal] = useState<
| { kind: 'customise'; stopId: string }
| { kind: 'detour'; afterStopId?: string }
| { kind: 'gpx' }
| null
>(null);
React.useEffect(() => {
fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {});
}, []);
const allStops: Stop[] = itinerary.days
.flatMap(d => d.stops)
.filter((s): s is Stop => s != null && typeof s.lat === 'number');
// Auto-populate origin/destination from first/last stop
React.useEffect(() => {
if (allStops.length > 0 && !origin) setOrigin(allStops[0].name);
if (allStops.length > 0 && !destination) setDestination(allStops[allStops.length - 1].name);
}, [allStops.length]);
const legByFromId = React.useMemo(() => {
const map = new Map<string, Leg>();
for (const l of legs) map.set(l.fromId, l);
return map;
}, [legs]);
React.useEffect(() => {
let cancelled = false;
const fetchRoutes = async () => {
if (allStops.length < 2) { setLegs([]); return; }
const fetched: Leg[] = [];
for (let i = 0; i < allStops.length - 1; i++) {
const leg = await getRoadLeg(allStops[i], allStops[i + 1]);
if (cancelled) return;
fetched.push(leg);
}
if (cancelled) return;
setLegs(fetched);
// Refresh cache with the up-to-date legs for the current variant
setVariantCache(prev => ({ ...prev, [selectedVariant]: { itinerary, legs: fetched } }));
};
fetchRoutes();
return () => { cancelled = true; };
}, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Prefetch other variants in the background ─────────────────────────────
const prefetchedRef = React.useRef<Set<string>>(new Set());
const prefetchKey = React.useMemo(() => {
if (allStops.length < 2) return null;
return `${allStops[0]?.name}__${allStops[allStops.length - 1]?.name}`;
}, [allStops]);
// Drop cached variants whose journey doesn't match the current origin/destination.
const lastPrefetchKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!prefetchKey) return;
if (lastPrefetchKey.current && lastPrefetchKey.current !== prefetchKey) {
setVariantCache(prev => {
const next: typeof prev = {};
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
return next;
});
}
lastPrefetchKey.current = prefetchKey;
}, [prefetchKey, selectedVariant]);
// Drop variant cache + prefetch ledger when travel dates change — pricing is stale.
const lastDatesKey = React.useRef<string>('');
React.useEffect(() => {
const key = `${travelDates.outbound || ''}|${travelDates.return || ''}|${travelDates.travellers || ''}`;
if (lastDatesKey.current && lastDatesKey.current !== key) {
setVariantCache(prev => {
const next: typeof prev = {};
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
return next;
});
prefetchedRef.current.clear();
}
lastDatesKey.current = key;
}, [travelDates.outbound, travelDates.return, travelDates.travellers, selectedVariant]);
React.useEffect(() => {
if (!prefetchKey) return;
if (thinking || variantSwitching) return;
if (variants.length < 2) return;
const targets: RouteVariant['id'][] = ['fast', 'scenic', 'cheap'];
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (!lastUserMsg) return;
let cancelled = false;
(async () => {
for (const id of targets) {
if (cancelled) return;
if (id === selectedVariant) continue;
if (variantCache[id]) continue;
const key = `${prefetchKey}::${id}`;
if (prefetchedRef.current.has(key)) continue;
prefetchedRef.current.add(key);
try {
await sendMessage(`Replan the trip as the ${id} variant`, { variant: id, silent: true, prefetch: true });
} catch (err) {
console.warn('[TeslaTrip] prefetch failed for', id, err);
prefetchedRef.current.delete(key);
}
}
})();
return () => { cancelled = true; };
}, [prefetchKey, variants.length, thinking, variantSwitching, selectedVariant]); // eslint-disable-line react-hooks/exhaustive-deps
const computedTotals = React.useMemo(() => {
if (legs.length === 0) return null;
const km = legs.reduce((s, l) => s + (l.distanceKm ?? 0), 0);
const min = legs.reduce((s, l) => s + (l.durationMin ?? 0), 0);
return { totalKm: km, driveMinutes: min };
}, [legs]);
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean; prefetch?: boolean } = {}) => {
const trimmed = text.trim();
if (!trimmed) return;
const variantToUse = opts.variant ?? selectedVariant;
if (!opts.silent && !opts.prefetch) {
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
setChatInput('');
setChips(prev => [...prev, trimmed].slice(-6));
}
if (opts.prefetch) {
// Background prefetch: never touch the visible itinerary or any spinner state.
} else if (opts.variant) {
setVariantSwitching(true);
} else {
setThinking(true);
setThinkingMessage('');
}
let lastPartialItinerary: any = null;
let lastVariants: any[] | null = null;
let lastSelectedVariant: RouteVariant['id'] | null = null;
let finalReply = '';
try {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: trimmed,
vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm },
itinerary,
history: messages.map(m => ({ role: m.role, content: m.content })),
selectedVariant: variantToUse,
origin: origin.trim() || undefined,
destination: destination.trim() || undefined,
travelDates: (travelDates.outbound || travelDates.return || travelDates.travellers)
? travelDates : undefined,
}),
});
if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
let blankIdx: number;
while ((blankIdx = sseBuffer.indexOf('\n\n')) !== -1) {
const block = sseBuffer.slice(0, blankIdx);
sseBuffer = sseBuffer.slice(blankIdx + 2);
let evName = 'message';
let evData = '';
for (const rawLine of block.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('event:')) evName = line.slice(6).trim();
else if (line.startsWith('data:')) evData += line.slice(5).trim();
}
if (!evData) continue;
let payload: any = null;
try { payload = JSON.parse(evData); } catch { continue; }
if (evName === 'thinking') {
if (!opts.prefetch && typeof payload.message === 'string' && payload.message.trim()) {
setThinkingMessage(payload.message.trim());
}
} else if (evName === 'partial') {
lastPartialItinerary = payload.itinerary;
if (Array.isArray(payload.variants)) lastVariants = payload.variants;
if (!opts.prefetch) {
// Use synchronous normalizer for partials — no geocoding, no blocking
if (payload.itinerary) {
setItinerary(normalizePartialItinerary(payload.itinerary));
}
if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants));
}
}
} else if (evName === 'done') {
finalReply = payload.reply || '';
if (payload.itinerary) {
const clean = await normalizeAndSanitizeItinerary(payload.itinerary);
const variantJustRendered = typeof payload.selectedVariant === 'string'
? payload.selectedVariant as RouteVariant['id']
: opts.variant ?? selectedVariant;
setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } }));
lastSelectedVariant = variantJustRendered;
if (!opts.prefetch) {
setItinerary(clean);
}
}
if (!opts.prefetch) {
if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants));
}
if (typeof payload.selectedVariant === 'string') {
setSelectedVariant(payload.selectedVariant as RouteVariant['id']);
} else if (opts.variant) {
setSelectedVariant(opts.variant);
}
}
} else if (evName === 'error') {
throw new Error(payload.error || 'Stream error');
}
}
}
if (!opts.silent && !opts.prefetch && finalReply) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: finalReply }]);
}
if (lastPartialItinerary && !opts.silent && !opts.prefetch) {
toast.success('Grok finished your route');
} else if (opts.variant && !opts.prefetch && lastSelectedVariant) {
toast.success(`Switched to ${lastSelectedVariant} route`);
}
} catch (err: any) {
console.error('[TeslaTrip] Grok stream failed:', err);
if (!opts.silent && !opts.prefetch) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
}
} finally {
if (!opts.prefetch) {
setThinking(false);
setVariantSwitching(false);
setThinkingMessage('');
}
}
// touch unused refs so eslint stays quiet (we keep them as breadcrumbs)
void lastVariants;
};
const updateStop = (stopId: string, patch: Partial<Stop>) => {
const next = structuredClone(itinerary);
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === stopId);
if (idx !== -1) {
d.stops[idx] = { ...d.stops[idx], ...patch };
break;
}
}
setItinerary(next);
};
const insertDetour = (place: { name: string; lat: number; lng: number; type: StopType; description?: string }, afterStopId?: string) => {
const next = structuredClone(itinerary);
const newStop: Stop = {
id: `detour-${Date.now()}`,
name: place.name,
type: place.type,
lat: place.lat,
lng: place.lng,
day: 1,
order: 1,
description: place.description,
combo: null,
};
// Find insertion point — after afterStopId or at the end of the first day
if (afterStopId) {
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === afterStopId);
if (idx !== -1) {
newStop.day = d.day;
d.stops.splice(idx + 1, 0, newStop);
break;
}
}
} else if (next.days.length > 0) {
const lastDay = next.days[next.days.length - 1];
newStop.day = lastDay.day;
lastDay.stops.push(newStop);
} else {
next.days = [{ day: 1, stops: [newStop] }];
}
for (const d of next.days) d.stops.forEach((s, i) => { s.order = i + 1; });
setItinerary(next);
toast.success(`Added ${place.name} to your trip`);
};
const moveStop = (stopId: string, direction: -1 | 1) => {
// Move within the flat list across day boundaries — nudges by one slot.
const flat = allStops;
const idx = flat.findIndex(s => s.id === stopId);
if (idx < 0) return;
const targetIdx = idx + direction;
if (targetIdx < 0 || targetIdx >= flat.length) return;
reorderStops(stopId, flat[targetIdx].id);
};
const reorderStops = (dragId: string, targetId: string) => {
if (dragId === targetId) return;
const next = structuredClone(itinerary);
// Find the dragged stop across all days and remove it
let dragged: Stop | null = null;
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === dragId);
if (idx !== -1) {
dragged = d.stops.splice(idx, 1)[0] as Stop;
break;
}
}
if (!dragged) return;
// Insert before the target stop
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === targetId);
if (idx !== -1) {
dragged.day = d.day;
d.stops.splice(idx, 0, dragged);
break;
}
}
// Re-normalise order numbers within each day
for (const d of next.days) {
d.stops.forEach((s, i) => { s.order = i + 1; });
}
next.days = next.days.filter(d => d.stops.length > 0);
setItinerary(next);
toast.info('Stop reordered');
};
const useMyLocation = async () => {
const t = toast.loading('Locating your car…');
let lat: number | null = null;
let lng: number | null = null;
// Prefer Tesla Fleet API location when connected — it's the actual vehicle GPS.
if (tesla.state?.lat != null && tesla.state?.lng != null) {
lat = tesla.state.lat;
lng = tesla.state.lng;
} else {
const coords = await getBrowserLocation();
if (coords) { lat = coords.latitude; lng = coords.longitude; }
}
if (lat == null || lng == null) {
toast.error('Could not get your location', {
id: t,
description: tesla.status?.connected
? 'Your Tesla may be asleep — try waking it or type a postcode.'
: "Connect your Tesla account for vehicle GPS, or type a postcode.",
});
return;
}
const name = await reverseGeocode(lat, lng);
if (!name) {
const fallback = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
setOrigin(fallback);
toast.success('Got your location', { id: t, description: fallback });
return;
}
setOrigin(name);
toast.success('Origin set to your location', { id: t, description: name });
};
const handleODCommit = () => {
const from = origin.trim();
const to = destination.trim();
if (!from || !to) return;
if (lastODSent.current && lastODSent.current.from === from && lastODSent.current.to === to) return;
// First time: only fire if user has actually edited (we auto-populate from itinerary)
const auto = allStops.length > 0 && from === allStops[0].name && to === allStops[allStops.length - 1].name;
if (auto && !lastODSent.current) {
lastODSent.current = { from, to };
return;
}
lastODSent.current = { from, to };
sendMessage(`Replan the trip from ${from} to ${to}`);
};
const switchVariant = (variantId: RouteVariant['id']) => {
if (variantId === selectedVariant || variantSwitching || thinking) return;
// Cache the current variant before switching
if (itinerary.days.length > 0) {
setVariantCache(prev => ({ ...prev, [selectedVariant]: { itinerary, legs } }));
}
// If target is already cached, swap instantly with no Grok call
const cached = variantCache[variantId];
if (cached) {
setItinerary(cached.itinerary);
setLegs(cached.legs);
setSelectedVariant(variantId);
toast.success(`Switched to ${variantId} (cached)`);
return;
}
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (!lastUserMsg) {
toast.info('Send a trip prompt first');
return;
}
sendMessage(`Replan the trip as the ${variantId} variant`, { variant: variantId, silent: true });
};
const removeStop = (stopId: string) => {
const next = structuredClone(itinerary);
next.days.forEach(d => { d.stops = d.stops.filter(s => s.id !== stopId); });
next.days = next.days.filter(d => d.stops.length > 0);
setItinerary(next);
if (activeStopId === stopId) setActiveStopId(null);
toast.info('Stop removed');
};
const swapStop = (stopId: string, alt: AlternativeStop) => {
const next = structuredClone(itinerary);
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === stopId);
if (idx === -1) continue;
const original = d.stops[idx];
const originalAsAlt: AlternativeStop = {
id: original.id, name: original.name, type: original.type,
lat: original.lat, lng: original.lng,
description: original.description, combo: original.combo ?? null,
amenities: original.amenities, cuisine: original.cuisine ?? null,
priceLevel: original.priceLevel, chargeMinutes: original.chargeMinutes,
durationMin: original.durationMin,
deltaKm: typeof alt.deltaKm === 'number' ? -alt.deltaKm : undefined,
deltaMin: typeof alt.deltaMin === 'number' ? -alt.deltaMin : undefined,
reason: 'Original pick',
};
const otherAlts = (original.alternatives || []).filter(a => a.id !== alt.id);
d.stops[idx] = {
...original,
id: alt.id, name: alt.name, type: alt.type, lat: alt.lat, lng: alt.lng,
description: alt.description, combo: alt.combo ?? null,
amenities: alt.amenities, cuisine: alt.cuisine ?? null,
priceLevel: alt.priceLevel, chargeMinutes: alt.chargeMinutes,
durationMin: alt.durationMin, notes: undefined,
alternatives: [originalAsAlt, ...otherAlts],
};
setActiveStopId(alt.id);
}
setItinerary(next);
toast.success(`Swapped to ${alt.name}`);
};
const pickCrossing = (stopId: string, crossing: CrossingOption) => {
const next = structuredClone(itinerary);
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === stopId);
if (idx === -1) continue;
const stop = d.stops[idx];
if (!stop.crossingOptions) continue;
stop.crossingOptions = stop.crossingOptions.map(o => ({ ...o, isCurrent: o.id === crossing.id }));
stop.name = `${crossing.operator} · ${crossing.fromPort}${crossing.toPort}`;
stop.durationMin = crossing.durationMin;
stop.type = crossing.mode === 'tunnel' ? 'tunnel' : 'ferry';
}
setItinerary(next);
toast.success(`Switched crossing to ${crossing.operator}`, {
description: `${crossing.fromPort}${crossing.toPort} · ${formatDuration(crossing.durationMin)}`,
});
// Ask Grok to re-plan the road portions either side of the new crossing
sendMessage(`I picked the ${crossing.operator} crossing (${crossing.fromPort}${crossing.toPort}). Re-plan the stops on either side to match.`, { silent: true });
};
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
const dateLabels = ['Today', 'Tomorrow'];
return (
<ErrorBoundary>
<div className="flex flex-col h-screen overflow-hidden" style={{ background: 'var(--gd-bg)', color: 'var(--gd-text)' }}>
<TopBar
origin={origin} destination={destination}
onOriginChange={setOrigin} onDestinationChange={setDestination}
onODCommit={handleODCommit}
chatInput={chatInput} setChatInput={setChatInput}
onChatSubmit={() => sendMessage(chatInput)}
chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))}
vehicle={vehicle}
onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }}
grokStatus={grokStatus}
onOpenGpx={() => setModal({ kind: 'gpx' })}
travelDates={travelDates}
onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }}
onUseMyLocation={useMyLocation}
teslaStatus={tesla.status}
teslaState={tesla.state}
onConnectTesla={async () => {
if (tesla.owner?.required && !tesla.owner.authenticated) {
ownerLoginThenRef.current = async () => {
try { await startTeslaConnect(); }
catch { toast.error('Could not start Tesla OAuth'); }
};
setOwnerLoginOpen(true);
return;
}
try { await startTeslaConnect(); }
catch { toast.error('Could not start Tesla OAuth'); }
}}
onDisconnectTesla={async () => {
await disconnectTesla();
await tesla.refreshStatus();
toast.success('Tesla disconnected');
}}
inCar={inCar}
/>
{variants.length > 0 && (
<VariantStrip
variants={variants}
selected={selectedVariant}
onSelect={switchVariant}
switching={variantSwitching}
cachedIds={Object.keys(variantCache)}
showCompare={showCompare}
onToggleCompare={() => setShowCompare(s => !s)}
/>
)}
{/* Body: map left, rail right */}
<div className="flex flex-1 overflow-hidden">
{/* Map */}
<div className="flex-1 relative" style={{ background: '#0a0f1a' }}>
<MapContainer
center={[51.5, 2.0]} zoom={5}
style={{ height: '100%', width: '100%', background: '#0a0f1a' }}
zoomControl={false}
>
<TileLayer
attribution="&copy; OpenStreetMap contributors"
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
<MapAutoFit stops={allStops} />
<MapFlyTo stop={activeStop} />
{allStops.map(stop => {
const meta = stopMeta(stop.type);
const isActive = stop.id === activeStopId;
const isHover = stop.id === hoverStopId;
return (
<Marker
key={stop.id}
position={[stop.lat, stop.lng]}
icon={makePinIcon(meta.color, isActive, isHover)}
eventHandlers={{
click: () => setActiveStopId(stop.id),
mouseover: () => setHoverStopId(stop.id),
mouseout: () => setHoverStopId(null),
}}
>
<Popup>
<div>
<div className="font-semibold text-[13px] mb-0.5">{stop.name}</div>
{stop.combo && (
<div className="text-[10px] font-semibold uppercase tracking-wider mb-1" style={{ color: 'var(--gd-red)' }}>
{stop.combo}
</div>
)}
{stop.description && <div className="text-[11.5px] leading-snug">{stop.description}</div>}
</div>
</Popup>
</Marker>
);
})}
{showCompare && Object.entries(variantCache)
.filter(([id]) => id !== selectedVariant)
.map(([id, cached]) => {
const variant = variants.find(v => v.id === id);
const color = variant?.tone === 'green' ? '#4ade80' : variant?.tone === 'blue' ? '#60a5fa' : '#e31937';
return cached.legs.map((leg, i) => (
<Polyline
key={`${id}-${i}`}
positions={leg.geometry}
pathOptions={{ color, weight: 2.4, opacity: 0.7, dashArray: '6 5' }}
/>
));
})}
{legs.map((leg, i) => (
<React.Fragment key={i}>
<Polyline positions={leg.geometry} pathOptions={{ color: 'var(--gd-red)', weight: 6, opacity: 0.18 }} />
<Polyline positions={leg.geometry} pathOptions={{ color: 'var(--gd-red)', weight: 2.4, opacity: 1 }} />
</React.Fragment>
))}
</MapContainer>
{/* Map legend (variants when comparing, else stop types) */}
{showCompare ? (
<div
className="absolute top-4 left-4 px-3 py-2 rounded-[10px] flex flex-col gap-1.5 text-[11px]"
style={{ background: 'rgba(20,20,24,0.85)', backdropFilter: 'blur(12px)', border: '1px solid var(--gd-border-2)', color: 'var(--gd-text-2)' }}
>
{variants.filter(v => v.id === selectedVariant || variantCache[v.id]).map(v => {
const isSel = v.id === selectedVariant;
const c = v.tone === 'green' ? '#4ade80' : v.tone === 'blue' ? '#60a5fa' : '#e31937';
return (
<div key={v.id} className="flex items-center gap-2">
<div
className="w-[18px] h-[3px] rounded"
style={{
background: isSel ? c : `repeating-linear-gradient(to right, ${c} 0 6px, transparent 6px 11px)`,
}}
/>
<div className="text-[12px]" style={{ color: isSel ? 'var(--gd-text)' : 'var(--gd-text-2)', fontWeight: isSel ? 500 : 400 }}>
{v.label}
</div>
</div>
);
})}
</div>
) : (
<div
className="absolute top-4 left-4 px-3 py-2 rounded-[10px] flex items-center gap-3.5 text-[11px]"
style={{ background: 'rgba(20,20,24,0.75)', backdropFilter: 'blur(12px)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
<div className="flex items-center gap-1.5"><Zap className="w-3 h-3" style={{ color: 'var(--gd-green)' }} />Charge</div>
<div className="flex items-center gap-1.5"><Bed className="w-3 h-3" style={{ color: 'var(--gd-blue)' }} />Sleep</div>
<div className="flex items-center gap-1.5"><Camera className="w-3 h-3" style={{ color: 'var(--gd-purple)' }} />See</div>
</div>
)}
{/* Refinements overlay */}
{chips.length > 0 && (
<div
className="absolute left-4 bottom-4 px-3.5 py-2.5 rounded-[12px] max-w-[540px]"
style={{ background: 'rgba(20,20,24,0.85)', backdropFilter: 'blur(12px)', border: '1px solid var(--gd-border-2)' }}
>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5" style={{ color: 'var(--gd-text-3)' }}>
Refinements applied · {chips.length}
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c, i) => (
<span
key={i}
className="text-[11.5px] px-2.5 py-1 rounded-full inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)', border: '1px solid var(--gd-red-line)' }}
>
<Sparkles className="w-2.5 h-2.5" /> {c}
</span>
))}
</div>
</div>
)}
{/* Empty state overlay */}
{allStops.length === 0 && !thinking && (
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div className="text-center max-w-md">
<div className="text-[20px] font-medium mb-2" style={{ color: 'var(--gd-text-2)' }}>Where to?</div>
<div className="text-[13.5px]" style={{ color: 'var(--gd-text-3)' }}>
Use the chat above to describe your trip. Try one of the quick prompts in the stops rail.
</div>
</div>
</div>
)}
{/* Thinking overlay on the map (when Grok is planning but nothing rendered yet) */}
{allStops.length === 0 && thinking && (
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div
className="text-center max-w-md px-6 py-5 rounded-2xl pointer-events-auto"
style={{
background: 'rgba(20,20,24,0.86)',
border: '1px solid var(--gd-border-2)',
backdropFilter: 'blur(14px)',
boxShadow: '0 12px 40px rgba(0,0,0,0.4)',
}}
>
<div className="flex items-center justify-center gap-2 mb-3">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
</div>
<div className="text-[11px] tracking-[0.15em] uppercase" style={{ color: 'var(--gd-red)' }}>
Grok is planning your route
</div>
</div>
{thinkingMessage ? (
<div
key={thinkingMessage}
className="text-[13px] leading-snug min-h-[40px]"
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
>
{thinkingMessage}
</div>
) : (
<div className="space-y-2 min-h-[40px]">
<SkeletonRow widthPct={92} />
<SkeletonRow widthPct={78} />
<SkeletonRow widthPct={85} />
</div>
)}
<div className="text-[10.5px] mt-3 tracking-wider uppercase" style={{ color: 'var(--gd-text-3)' }}>
Stops will appear as soon as they're chosen
</div>
</div>
</div>
)}
</div>
{/* Stops rail */}
<aside
className="flex flex-col overflow-hidden flex-shrink-0"
style={{ width: 540, background: 'var(--gd-bg-2)', borderLeft: '1px solid var(--gd-border)' }}
>
{/* Trip header */}
<div className="px-5 py-3.5 flex items-center gap-2.5" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div>
<div className="text-[13.5px] font-medium">
{allStops.length} stops · {itinerary.days.length || ''} day{itinerary.days.length === 1 ? '' : 's'}
</div>
<div className="text-[11px] num" style={{ color: 'var(--gd-text-3)' }}>
{computedTotals ? formatKm(computedTotals.totalKm) : `${itinerary.summary.totalDistanceKm} km`}
{' · '}
{computedTotals ? formatDuration(computedTotals.driveMinutes) : `${itinerary.summary.estDriveHours}h`} drive
{' · '}
{formatDuration(itinerary.summary.estChargeHours * 60)} charging
</div>
</div>
<div className="flex-1" />
<button
onClick={() => setModal({ kind: 'detour' })}
className="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-lg text-[11.5px] whitespace-nowrap"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)', color: 'var(--gd-red)' }}
>
<Plus className="w-3 h-3" /> Add stop
</button>
</div>
{/* Travel-dates nudge */}
{itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && (
<button
onClick={(e) => { setDateAnchor(e.currentTarget.getBoundingClientRect()); setDatePickerOpen(true); }}
className="mx-4 mt-3 px-3 py-2 rounded-lg text-left flex items-center gap-2.5 transition hover:bg-white/[0.02]"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
>
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--gd-red)' }} />
<div className="flex-1 min-w-0">
<div className="text-[11.5px] font-medium" style={{ color: 'var(--gd-red)' }}>Add travel dates for accurate pricing</div>
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>
Ferry, Eurotunnel and hotel prices vary by date — currently showing off-peak ballpark.
</div>
</div>
<ChevronDown className="w-3 h-3 rotate-[-90deg]" style={{ color: 'var(--gd-red)' }} />
</button>
)}
{/* Stops list */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{itinerary.days.length === 0 ? (
<div className="py-8 text-center">
<div className="text-[13px] mb-3" style={{ color: 'var(--gd-text-2)' }}>No trip planned yet.</div>
<div className="text-[11.5px] mb-4" style={{ color: 'var(--gd-text-3)' }}>Try one of these to get started:</div>
<div className="flex flex-col gap-1.5">
{QUICK_PROMPTS.map((p, i) => (
<button
key={i}
onClick={() => sendMessage(p)}
disabled={thinking}
className="text-left text-[12px] px-3 py-2 rounded-lg transition disabled:opacity-40"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
{p}
</button>
))}
</div>
</div>
) : (
itinerary.days.map((day, di) => {
const stops = (day.stops || []).filter(Boolean).sort((a, b) => a.order - b.order);
const dayLegs: Leg[] = [];
for (let i = 0; i < stops.length - 1; i++) {
const l = legByFromId.get(stops[i].id);
if (l && l.toId === stops[i + 1].id) dayLegs.push(l);
}
const dayKm = dayLegs.reduce((s, l) => s + (l.distanceKm ?? 0), 0);
const dayMin = dayLegs.reduce((s, l) => s + (l.durationMin ?? 0), 0);
const chargeMin = stops.reduce((s, st) => s + (st.chargeMinutes || 0), 0);
return (
<div key={di}>
<DayHeader
dayNumber={day.day}
title={day.title}
distanceKm={dayKm}
driveMin={dayMin}
chargeMin={chargeMin}
dateLabel={dateLabels[di]}
/>
<div className="pt-3">
{stops.map((stop, si) => {
const isLast = si === stops.length - 1;
const leg = legByFromId.get(stop.id);
const hasNextDay = di < itinerary.days.length - 1;
const showNightBlock = isLast && hasNextDay && (stop.type === 'hotel' || stop.type === 'supercharger');
return (
<div key={stop.id}>
<StopCard
stop={stop}
active={stop.id === activeStopId}
hover={stop.id === hoverStopId}
dragging={stop.id === draggingId}
onSelect={() => setActiveStopId(stop.id === activeStopId ? null : stop.id)}
onHover={(h) => setHoverStopId(h ? stop.id : null)}
onSwap={(alt) => swapStop(stop.id, alt)}
onRemove={() => removeStop(stop.id)}
onCustomise={() => setModal({ kind: 'customise', stopId: stop.id })}
onPickCrossing={(c) => pickCrossing(stop.id, c)}
onMoveUp={() => moveStop(stop.id, -1)}
onMoveDown={() => moveStop(stop.id, 1)}
canMoveUp={allStops[0]?.id !== stop.id}
canMoveDown={allStops[allStops.length - 1]?.id !== stop.id}
onSendToNav={async () => {
const ok = await sendToTeslaNav({ lat: stop.lat, lng: stop.lng, name: stop.name });
if (ok) toast.success(`Sent to Tesla nav`, { description: stop.name });
else toast.error('Could not send to nav', { description: 'Car may be asleep try wake.' });
}}
canSendToNav={!!tesla.status?.connected}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', stop.id);
e.dataTransfer.effectAllowed = 'move';
setDraggingId(stop.id);
}}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => {
e.preventDefault();
const dragId = e.dataTransfer.getData('text/plain');
if (dragId && dragId !== stop.id) reorderStops(dragId, stop.id);
setDraggingId(null);
}}
onDragEnd={() => setDraggingId(null)}
/>
{!isLast && <LegRow leg={leg} />}
{showNightBlock && (
<NightBlock
lastStop={stop}
onOpenHotelOptions={() => setActiveStopId(stop.id)}
/>
)}
</div>
);
})}
</div>
</div>
);
})
)}
{itinerary.days.length > 0 && (
<button
onClick={() => setModal({ kind: 'detour' })}
className="w-full h-10 mt-3 rounded-[10px] text-[12px] inline-flex items-center justify-center gap-1.5 hover:bg-white/[0.02] transition"
style={{ border: '1px dashed var(--gd-border-2)', background: 'transparent', color: 'var(--gd-text-3)' }}
>
<Plus className="w-3 h-3" /> Add a detour or stop
</button>
)}
{thinking && (
<div className="mt-4 px-3 py-2.5 rounded-lg" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<div className="flex items-center gap-2 mb-1">
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
</div>
<div className="text-[10px] tracking-wider uppercase" style={{ color: 'var(--gd-red)' }}>
{itinerary.days.length > 0 ? 'Refining your route' : 'Grok is planning your route'}
</div>
</div>
{thinkingMessage && (
<div
className="text-[12px] leading-snug mt-1.5 line-clamp-3"
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
key={thinkingMessage}
>
{thinkingMessage}
</div>
)}
{!thinkingMessage && itinerary.days.length === 0 && (
<div className="mt-2 space-y-1.5">
<SkeletonRow widthPct={92} />
<SkeletonRow widthPct={78} />
<SkeletonRow widthPct={85} />
</div>
)}
</div>
)}
{itinerary.summary.highlights && itinerary.summary.highlights.length > 0 && (
<div className="mt-5 pt-4" style={{ borderTop: '1px solid var(--gd-border)' }}>
<div className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--gd-red)' }}>Highlights</div>
<ul className="space-y-1.5 text-[12px]" style={{ color: 'var(--gd-text-2)' }}>
{itinerary.summary.highlights.map((h, i) => (
<li key={i} className="flex gap-2">
<span style={{ color: 'var(--gd-red)' }}>★</span>
<span>{h}</span>
</li>
))}
</ul>
</div>
)}
</div>
</aside>
</div>
</div>
{modal?.kind === 'customise' && (
<CustomiseStopModal
stop={allStops.find(s => s.id === modal.stopId) || itinerary.days.flatMap(d => d.stops).find(s => s.id === modal.stopId) || null}
onClose={() => setModal(null)}
onApply={(patch) => { updateStop(modal.stopId, patch); setModal(null); toast.success('Stop updated'); }}
/>
)}
{modal?.kind === 'detour' && (
<AddDetourOverlay
onClose={() => setModal(null)}
onInsert={(place) => insertDetour(place, modal.afterStopId)}
/>
)}
{modal?.kind === 'gpx' && (
<GpxExportModal itinerary={itinerary} onClose={() => setModal(null)} />
)}
<VehicleSelectorPanel
open={vehiclePanelOpen}
anchorRect={vehicleAnchor}
selected={vehicle}
onSelect={setVehicle}
onClose={() => setVehiclePanelOpen(false)}
/>
<TravelDatesPanel
open={datePickerOpen}
anchorRect={dateAnchor}
value={travelDates}
onChange={setTravelDates}
onClose={() => setDatePickerOpen(false)}
onApply={() => {
setDatePickerOpen(false);
if (allStops.length > 0) {
sendMessage(`Update the itinerary for travel dates ${formatDateRange(travelDates.outbound, travelDates.return)}`, { silent: true });
}
}}
/>
<OwnerLoginModal
open={ownerLoginOpen}
onClose={() => setOwnerLoginOpen(false)}
onSuccess={async () => {
setOwnerLoginOpen(false);
await tesla.refreshStatus();
const fn = ownerLoginThenRef.current;
ownerLoginThenRef.current = null;
if (fn) fn();
}}
/>
</ErrorBoundary>
);
}
function OwnerLoginModal({ open, onClose, onSuccess }: {
open: boolean; onClose: () => void; onSuccess: () => void;
}) {
const [secret, setSecret] = React.useState('');
const [pending, setPending] = React.useState(false);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const submit = async () => {
if (!secret) return;
setPending(true);
try {
const ok = await loginOwner(secret);
if (ok) {
toast.success('Logged in as owner');
setSecret('');
onSuccess();
} else {
toast.error('Invalid owner secret');
}
} finally {
setPending(false);
}
};
return (
<div
onClick={onClose}
className="fixed inset-0 grid place-items-center p-6"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-[440px] max-w-full overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-2)',
borderRadius: 16,
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
}}
>
<div className="px-5 py-4" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="text-[14px] font-semibold">Owner login required</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>
The Tesla integration is restricted to the deploying user. Enter the OWNER_SECRET set in the deploy environment.
</div>
</div>
<div className="px-5 py-4">
<input
type="password"
autoFocus
value={secret}
onChange={(e) => setSecret(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
placeholder="Owner secret"
className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
/>
</div>
<div className="px-5 py-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button
onClick={onClose}
className="flex-1 h-10 text-[12.5px] rounded-lg"
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Cancel
</button>
<button
onClick={submit}
disabled={!secret || pending}
className="flex-[2] h-10 text-[12.5px] rounded-lg disabled:opacity-40"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
{pending ? 'Logging in' : 'Log in'}
</button>
</div>
</div>
</div>
);
}
// ─── Modal shell ─────────────────────────────────────────────────────────────
function ModalShell({
onClose, width = 720, title, subtitle, footer, children,
}: {
onClose: () => void;
width?: number;
title?: string;
subtitle?: string;
footer?: React.ReactNode;
children: React.ReactNode;
}) {
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div
onClick={onClose}
className="fixed inset-0 grid place-items-center p-6"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex flex-col overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-2)',
borderRadius: 16,
width,
maxWidth: '100%',
maxHeight: '90vh',
boxShadow: '0 32px 80px rgba(0,0,0,0.5)',
}}
>
{title && (
<div className="px-5 py-4 flex items-center gap-3 flex-shrink-0" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-medium" style={{ letterSpacing: '-0.01em' }}>{title}</div>
{subtitle && <div className="text-[12px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>{subtitle}</div>}
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg grid place-items-center hover:bg-white/5 transition"
style={{ color: 'var(--gd-text-2)' }}
>
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto min-h-0">{children}</div>
{footer && (
<div className="px-5 py-3.5 flex items-center gap-2.5 flex-shrink-0" style={{ borderTop: '1px solid var(--gd-border)' }}>
{footer}
</div>
)}
</div>
</div>
);
}
// ─── Customise Stop modal (Charger / Overnight / Duration / Things / Detour) ─
type CustomiseTab = 'charger' | 'overnight' | 'duration' | 'things' | 'detour';
function CustomiseStopModal({
stop, onClose, onApply,
}: {
stop: Stop | null;
onClose: () => void;
onApply: (patch: Partial<Stop>) => void;
}) {
if (!stop) return null;
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isSleep = stop.type === 'hotel';
const initialTab: CustomiseTab = isCharge ? 'charger' : isSleep ? 'overnight' : 'things';
const [tab, setTab] = React.useState<CustomiseTab>(initialTab);
const [chargeMinutes, setChargeMinutes] = React.useState(stop.chargeMinutes || 30);
const [durationMin, setDurationMin] = React.useState(stop.durationMin || stop.chargeMinutes || 30);
const [pickedNearby, setPickedNearby] = React.useState<Set<string>>(new Set());
const [chosenChargerId, setChosenChargerId] = React.useState<string>(stop.chargerOptions?.find(c => c.isCurrent)?.id || stop.chargerOptions?.[0]?.id || '');
const tabs: { id: CustomiseTab; label: string; icon: IconComponent; count?: number; show: boolean }[] = [
{ id: 'charger', label: 'Charger', icon: Zap, count: stop.chargerOptions?.length, show: isCharge },
{ id: 'overnight', label: 'Overnight', icon: Bed, count: stop.alternatives?.length, show: isSleep },
{ id: 'duration', label: 'Duration', icon: Clock, show: true },
{ id: 'things', label: 'Things to do', icon: Footprints, count: stop.nearby?.length, show: (stop.nearby?.length ?? 0) > 0 },
{ id: 'detour', label: 'Detour', icon: Route, show: true },
];
return (
<ModalShell
onClose={onClose}
width={780}
title={`Customise · ${stop.name}`}
subtitle={stop.description?.slice(0, 80)}
footer={
<>
<div className="text-[12px] num" style={{ color: 'var(--gd-text-3)' }}>
<span style={{ color: 'var(--gd-text)' }}>Total stop:</span> {durationMin + pickedNearby.size * 8} min
</div>
<div className="flex-1" />
<button
onClick={onClose}
className="h-9 px-3.5 rounded-lg text-[13px] inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
>
Cancel
</button>
<button
onClick={() => onApply({ chargeMinutes, durationMin })}
className="h-9 px-4 rounded-lg text-[13px] font-medium inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
<Sparkles className="w-3.5 h-3.5" /> Apply changes
</button>
</>
}
>
<div className="flex" style={{ minHeight: 460 }}>
{/* Tab rail */}
<div className="w-[200px] py-3.5 px-3 flex flex-col gap-0.5 flex-shrink-0" style={{ borderRight: '1px solid var(--gd-border)' }}>
{tabs.filter(t => t.show).map(t => {
const TI = t.icon;
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left text-[13px] transition"
style={{
background: active ? 'var(--gd-panel-2)' : 'transparent',
color: active ? 'var(--gd-text)' : 'var(--gd-text-2)',
fontWeight: active ? 500 : 400,
}}
>
<TI size={14} className="" />
<span className="flex-1">{t.label}</span>
{t.count != null && (
<span
className="text-[10.5px] num px-1.5 py-px rounded"
style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--gd-text-3)' }}
>
{t.count}
</span>
)}
</button>
);
})}
</div>
{/* Body */}
<div className="flex-1 p-5 overflow-y-auto">
{tab === 'charger' && (
<CustomiseChargerTab
options={stop.chargerOptions || []}
chosenId={chosenChargerId}
onChoose={setChosenChargerId}
/>
)}
{tab === 'overnight' && (
<CustomiseOvernightTab alternatives={stop.alternatives || []} currentName={stop.name} />
)}
{tab === 'duration' && (
<CustomiseDurationTab
value={durationMin}
onChange={setDurationMin}
chargeMinutes={chargeMinutes}
onChargeChange={setChargeMinutes}
isCharge={isCharge}
arrivePct={stop.estArrivalBattery}
/>
)}
{tab === 'things' && (
<CustomiseThingsTab
nearby={stop.nearby || []}
picked={pickedNearby}
onTogglePick={(name) => {
const next = new Set(pickedNearby);
if (next.has(name)) next.delete(name); else next.add(name);
setPickedNearby(next);
}}
/>
)}
{tab === 'detour' && <CustomiseDetourTab />}
</div>
</div>
</ModalShell>
);
}
function CustomiseChargerTab({ options, chosenId, onChoose }: {
options: ChargerOption[]; chosenId: string; onChoose: (id: string) => void;
}) {
if (options.length === 0) {
return <EmptyTab text="No alternative chargers near this stop." />;
}
return (
<div>
<SectionLabel>Choose a charger</SectionLabel>
<div className="flex flex-col gap-2">
{options.map(o => {
const sel = o.id === chosenId;
return (
<div
key={o.id}
onClick={() => onChoose(o.id)}
className="p-3.5 rounded-[12px] cursor-pointer transition"
style={{
background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${sel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
}}
>
<div className="flex items-start gap-3">
<div
className="w-[18px] h-[18px] rounded-full grid place-items-center flex-shrink-0 mt-0.5"
style={{
border: `2px solid ${sel ? 'var(--gd-red)' : 'var(--gd-border-2)'}`,
background: sel ? 'var(--gd-red)' : 'transparent',
}}
>
{sel && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-[14px] font-medium">{o.name}</div>
{o.badge && (
<span
className="text-[9.5px] px-1.5 py-px rounded whitespace-nowrap"
style={{ color: o.isCurrent ? 'var(--gd-red)' : 'var(--gd-amber)', border: `1px solid ${o.isCurrent ? 'var(--gd-red-line)' : 'rgba(251,191,36,0.4)'}` }}
>
{o.badge}
</span>
)}
</div>
<div className="text-[11.5px] mt-1 flex gap-3.5 flex-wrap num" style={{ color: 'var(--gd-text-3)' }}>
<span className="inline-flex items-center gap-1.5">
<Zap className="w-3 h-3" style={{ color: 'var(--gd-green)' }} /> {o.kw} kW · {o.stalls} stalls
</span>
<span className="inline-flex items-center gap-1.5">
<Euro className="w-3 h-3" /> €{o.pricePerKwh.toFixed(2)}/kWh
</span>
{o.detourMin > 0 ? (
<span style={{ color: 'var(--gd-amber)' }}>+{o.detourMin} min detour</span>
) : (
<span style={{ color: 'var(--gd-green)' }}>On route</span>
)}
{o.network && <span>{o.network}</span>}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
function CustomiseOvernightTab({ alternatives, currentName }: { alternatives: AlternativeStop[]; currentName: string }) {
if (alternatives.length === 0) {
return <EmptyTab text="No alternative overnight options were suggested for this stop." />;
}
return (
<div>
<SectionLabel>Choose where to sleep</SectionLabel>
<div className="flex flex-col gap-2.5">
<div
className="p-3.5 rounded-[12px]"
style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.4)' }}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-[9px] grid place-items-center" style={{ background: 'rgba(96,165,250,0.2)' }}>
<Bed className="w-4 h-4" style={{ color: 'var(--gd-blue)' }} />
</div>
<div className="flex-1">
<div className="text-[14px] font-medium">{currentName}</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>Currently selected</div>
</div>
<span className="text-[10.5px]" style={{ color: 'var(--gd-blue)' }}>Current</span>
</div>
</div>
{alternatives.map(a => (
<div
key={a.id}
className="p-3.5 rounded-[12px]"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-[9px] grid place-items-center" style={{ background: 'var(--gd-panel-2)' }}>
<Bed className="w-4 h-4" style={{ color: 'var(--gd-text-2)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium truncate">{a.name}</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>{a.reason || a.description}</div>
</div>
<button
className="h-7 px-2.5 rounded-lg text-[11px]"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
Choose
</button>
</div>
</div>
))}
</div>
</div>
);
}
function CustomiseDurationTab({ value, onChange, chargeMinutes, onChargeChange, isCharge, arrivePct }: {
value: number; onChange: (v: number) => void;
chargeMinutes: number; onChargeChange: (v: number) => void;
isCharge: boolean; arrivePct?: number;
}) {
const leavePct = arrivePct != null ? Math.min(100, arrivePct + Math.round(chargeMinutes * 1.4)) : null;
const sliderPct = ((value - 10) / 110) * 100;
return (
<div>
<SectionLabel>How long here?</SectionLabel>
<div className="flex items-baseline gap-2 mb-5">
<div className="text-[48px] font-light num" style={{ letterSpacing: '-0.03em' }}>{value}</div>
<div className="text-[16px]" style={{ color: 'var(--gd-text-2)' }}>minutes</div>
<div className="flex-1" />
{isCharge && arrivePct != null && (
<div className="text-[12px] num" style={{ color: 'var(--gd-text-3)' }}>
arrive {arrivePct}% · leave <span style={{ color: 'var(--gd-green)' }}>{leavePct}%</span>
</div>
)}
</div>
<div className="relative h-10 mb-6">
<input
type="range" min={10} max={120} step={5} value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
className="absolute inset-0 w-full h-10 opacity-0 z-10 cursor-pointer"
/>
<div className="absolute top-[18px] left-0 right-0 h-1 rounded" style={{ background: 'var(--gd-border)' }} />
<div className="absolute top-[18px] left-0 h-1 rounded" style={{ width: `${sliderPct}%`, background: 'var(--gd-red)' }} />
<div
className="absolute top-3 w-4 h-4 rounded-full"
style={{
left: `calc(${sliderPct}% - 8px)`,
background: '#fff',
border: '3px solid var(--gd-red)',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
/>
{[15, 30, 45, 60, 90, 120].map(t => (
<div
key={t}
className="absolute top-7 text-[10px] num w-7 text-center"
style={{ left: `calc(${((t - 10) / 110) * 100}% - 14px)`, color: 'var(--gd-text-3)' }}
>
{t}m
</div>
))}
</div>
<SectionLabel>Presets</SectionLabel>
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'Quick top-up', mins: 15, sub: 'just enough to reach next' },
{ label: 'Coffee + restroom', mins: 30, sub: 'short walk, restrooms' },
{ label: 'Sit-down lunch', mins: 60, sub: 'café or restaurant nearby' },
{ label: 'Explore the town', mins: 90, sub: 'old town loop + lunch' },
{ label: 'Full charge', mins: 120, sub: 'to 100% if needed' },
{ label: 'Skip & risk it', mins: 10, sub: 'bypass entirely', warn: true },
].map(p => (
<button
key={p.label}
onClick={() => { onChange(p.mins); if (isCharge) onChargeChange(p.mins); }}
className="p-3 rounded-[9px] text-left"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<div className="text-[12px] font-medium" style={{ color: p.warn ? 'var(--gd-amber)' : 'var(--gd-text)' }}>{p.label}</div>
<div className="text-[10.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>{p.sub}</div>
</button>
))}
</div>
</div>
);
}
function CustomiseThingsTab({ nearby, picked, onTogglePick }: {
nearby: NearbyPlace[]; picked: Set<string>; onTogglePick: (name: string) => void;
}) {
if (nearby.length === 0) {
return <EmptyTab text="No nearby places suggested for this stop." />;
}
return (
<div>
<SectionLabel>Pick what you want to do here</SectionLabel>
<div className="grid grid-cols-2 gap-2">
{nearby.map((n, i) => {
const sel = picked.has(n.name);
return (
<div
key={i}
onClick={() => onTogglePick(n.name)}
className="p-3 rounded-[10px] flex items-center gap-2.5 cursor-pointer transition"
style={{
background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${sel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
}}
>
<div
className="w-9 h-9 rounded-[9px] grid place-items-center flex-shrink-0 text-[15px] leading-none"
style={{ background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel-2)' }}
>
{AMENITY_ICONS[n.icon] || '📍'}
</div>
<div className="flex-1 min-w-0">
<div className="text-[12.5px] font-medium truncate">{n.name}</div>
<div className="text-[10.5px] mt-0.5 truncate" style={{ color: 'var(--gd-text-3)' }}>{n.detail}</div>
</div>
<div
className="w-[18px] h-[18px] rounded grid place-items-center flex-shrink-0"
style={{
border: `1.5px solid ${sel ? 'var(--gd-red)' : 'var(--gd-border-2)'}`,
background: sel ? 'var(--gd-red)' : 'transparent',
}}
>
{sel && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth={3}><path d="M5 12l5 5 9-11" /></svg>}
</div>
</div>
);
})}
</div>
</div>
);
}
function CustomiseDetourTab() {
return (
<div>
<SectionLabel>Add a detour from this stop</SectionLabel>
<div className="p-4 rounded-[12px] flex items-center gap-2" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<MapPin className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-3)' }} />
<input
placeholder="Search for a town, attraction, restaurant…"
className="flex-1 bg-transparent outline-none border-none text-[13px]"
style={{ color: 'var(--gd-text)' }}
/>
</div>
<div className="text-[11px] mt-3" style={{ color: 'var(--gd-text-3)' }}>
Search is mocked — use the "Add stop" button in the rail header to insert a detour from the popular-places list.
</div>
</div>
);
}
function EmptyTab({ text }: { text: string }) {
return (
<div className="h-full grid place-items-center min-h-[260px]">
<div className="text-center max-w-xs">
<div className="text-[13px]" style={{ color: 'var(--gd-text-2)' }}>{text}</div>
</div>
</div>
);
}
// ─── Add Detour overlay ──────────────────────────────────────────────────────
const POPULAR_DETOURS: { name: string; lat: number; lng: number; type: StopType; description: string; detourMin: number }[] = [
{ name: 'York Minster', lat: 53.962, lng: -1.082, type: 'attraction', description: 'Gothic cathedral · 12th century', detourMin: 25 },
{ name: 'Lake District National Park', lat: 54.4609, lng: -3.0886, type: 'viewpoint', description: 'England\'s largest national park', detourMin: 45 },
{ name: 'Tebay Services Farm Shop', lat: 54.4331, lng: -2.6049, type: 'restaurant', description: 'Independent farm shop on the M6', detourMin: 5 },
{ name: 'Beaune town centre', lat: 47.0241, lng: 4.8398, type: 'attraction', description: 'Cobbled lanes, mustard shop, hospices', detourMin: 4 },
{ name: 'Hadrian\'s Wall', lat: 55.0114, lng: -2.2854, type: 'attraction', description: 'Roman wall · UNESCO World Heritage', detourMin: 30 },
{ name: 'Bamburgh Castle', lat: 55.6086, lng: -1.7102, type: 'attraction', description: 'Coastal castle · Northumbrian icon', detourMin: 35 },
];
function AddDetourOverlay({
onClose, onInsert,
}: {
onClose: () => void;
onInsert: (place: typeof POPULAR_DETOURS[number]) => void;
}) {
const [query, setQuery] = React.useState('');
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const filtered = POPULAR_DETOURS.filter(p => !query || p.name.toLowerCase().includes(query.toLowerCase()));
return (
<div
onClick={onClose}
className="fixed inset-0 flex justify-center items-start px-6 pt-[90px]"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-[640px] max-w-full overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-2)',
borderRadius: 14,
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
<div className="px-4 py-3.5 flex items-center gap-3" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<MapPin className="w-4 h-4" style={{ color: 'var(--gd-red)' }} />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Add a stop — city, attraction, charger, restaurant…"
className="flex-1 bg-transparent outline-none text-[15px]"
style={{ color: 'var(--gd-text)' }}
/>
<kbd className="mono text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--gd-text-3)', border: '1px solid var(--gd-border)' }}>esc</kbd>
</div>
<div className="p-2 max-h-[380px] overflow-y-auto">
{filtered.length === 0 ? (
<div className="text-[12px] p-4 text-center" style={{ color: 'var(--gd-text-3)' }}>
No matches in the popular-detours list. Real search coming soon.
</div>
) : (
filtered.map(r => (
<div
key={r.name}
className="px-3 py-2.5 rounded-[9px] flex items-center gap-3 hover:bg-white/5 transition"
>
<div className="w-8 h-8 rounded-[7px] grid place-items-center flex-shrink-0" style={{ background: 'var(--gd-panel-2)' }}>
<MapPin className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{r.name}</div>
<div className="text-[11px] truncate" style={{ color: 'var(--gd-text-3)' }}>{r.description} · +{r.detourMin} min</div>
</div>
<button
onClick={() => { onInsert(r); onClose(); }}
className="h-7 px-2.5 rounded-lg text-[11px] inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)', border: '1px solid var(--gd-red-line)' }}
>
<Plus className="w-3 h-3" /> Insert
</button>
</div>
))
)}
</div>
</div>
</div>
);
}
// ─── GPX Export modal ────────────────────────────────────────────────────────
type ExportFormat = 'gpx' | 'kml' | 'csv';
function generateGpx(itinerary: Itinerary, includeNotes: boolean, includeNearby: boolean): string {
const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number');
const header = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Grok Drive" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${escapeXml(allStops[0]?.name || 'Trip')}${escapeXml(allStops[allStops.length - 1]?.name || 'Destination')}</name>
<desc>${itinerary.summary.totalDistanceKm} km · ${itinerary.summary.estDriveHours}h drive · ${itinerary.summary.estChargeHours}h charging</desc>
<time>${new Date().toISOString()}</time>
</metadata>
<trk>
<name>Grok Drive route</name>
<trkseg>`;
const points = allStops.map(s => {
const inner: string[] = [
` <name>${escapeXml(s.name)}</name>`,
` <type>${escapeXml(s.type)}</type>`,
];
if (includeNotes && s.description) inner.push(` <desc>${escapeXml(s.description)}</desc>`);
if (includeNearby && s.nearby && s.nearby.length > 0) {
inner.push(` <cmt>Nearby: ${escapeXml(s.nearby.slice(0, 3).map(n => n.name).join(', '))}</cmt>`);
}
return ` <trkpt lat="${s.lat.toFixed(5)}" lon="${s.lng.toFixed(5)}">\n${inner.join('\n')}\n </trkpt>`;
}).join('\n');
return `${header}\n${points}\n </trkseg>\n </trk>\n</gpx>`;
}
function generateKml(itinerary: Itinerary): string {
const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number');
const placemarks = allStops.map(s => ` <Placemark>
<name>${escapeXml(s.name)}</name>
<description>${escapeXml(s.description || s.type)}</description>
<Point><coordinates>${s.lng.toFixed(5)},${s.lat.toFixed(5)},0</coordinates></Point>
</Placemark>`).join('\n');
const lineCoords = allStops.map(s => `${s.lng.toFixed(5)},${s.lat.toFixed(5)},0`).join(' ');
return `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Grok Drive route</name>
${placemarks}
<Placemark><name>Route</name><LineString><coordinates>${lineCoords}</coordinates></LineString></Placemark>
</Document>
</kml>`;
}
function generateCsv(itinerary: Itinerary): string {
const rows = [
'day,order,name,type,lat,lng,charge_minutes,arrive_battery_pct,combo,description',
];
for (const d of itinerary.days) {
for (const s of d.stops) {
rows.push([
d.day, s.order, q(s.name), s.type, s.lat ?? '', s.lng ?? '',
s.chargeMinutes ?? '', s.estArrivalBattery ?? '',
q(s.combo || ''), q(s.description || ''),
].join(','));
}
}
return rows.join('\n');
function q(v: string) { return `"${v.replace(/"/g, '""')}"`; }
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function downloadFile(filename: string, content: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function GpxExportModal({ itinerary, onClose }: { itinerary: Itinerary; onClose: () => void }) {
const [format, setFormat] = React.useState<ExportFormat>('gpx');
const [includeNotes, setIncludeNotes] = React.useState(true);
const [includeNearby, setIncludeNearby] = React.useState(false);
const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number');
const content = format === 'gpx'
? generateGpx(itinerary, includeNotes, includeNearby)
: format === 'kml'
? generateKml(itinerary)
: generateCsv(itinerary);
const baseName = `grok-drive-${(allStops[0]?.name || 'trip').toLowerCase().replace(/\s+/g, '-')}-${(allStops[allStops.length - 1]?.name || 'route').toLowerCase().replace(/\s+/g, '-')}`;
const filename = `${baseName}.${format}`;
const handleDownload = () => {
const mime = format === 'gpx' ? 'application/gpx+xml' : format === 'kml' ? 'application/vnd.google-earth.kml+xml' : 'text/csv';
downloadFile(filename, content, mime);
toast.success(`Downloaded ${filename}`);
};
if (allStops.length === 0) {
return (
<ModalShell
onClose={onClose}
width={520}
title="Export trip"
footer={
<>
<div className="flex-1" />
<button
onClick={onClose}
className="h-9 px-3.5 rounded-lg text-[13px]"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
Close
</button>
</>
}
>
<div className="p-6 text-center">
<div className="text-[13px]" style={{ color: 'var(--gd-text-2)' }}>
Plan a trip first there's nothing to export yet.
</div>
</div>
</ModalShell>
);
}
return (
<ModalShell
onClose={onClose}
width={820}
title="Export trip"
subtitle="Send this trip to your car, phone, or another planning app"
footer={
<>
<div className="text-[12px] num" style={{ color: 'var(--gd-text-3)' }}>
{content.split('\n').length} lines · ~{(content.length / 1024).toFixed(1)} KB
</div>
<div className="flex-1" />
<button
onClick={() => { navigator.clipboard.writeText(content); toast.success('Copied to clipboard'); }}
className="h-9 px-3.5 rounded-lg text-[13px] inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<Share2 className="w-3.5 h-3.5" /> Copy
</button>
<button
onClick={handleDownload}
className="h-9 px-4 rounded-lg text-[13px] font-medium inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
<Download className="w-3.5 h-3.5" /> Download .{format}
</button>
</>
}
>
<div className="flex" style={{ minHeight: 460 }}>
<div className="w-[260px] py-5 px-5 flex flex-col gap-4 flex-shrink-0" style={{ borderRight: '1px solid var(--gd-border)' }}>
<div>
<SectionLabel>Format</SectionLabel>
{([
{ id: 'gpx', name: 'GPX', detail: 'Tesla, car nav, ABRP, most apps' },
{ id: 'kml', name: 'KML', detail: 'Google Earth / Google Maps' },
{ id: 'csv', name: 'CSV', detail: 'Spreadsheet of stops' },
] as { id: ExportFormat; name: string; detail: string }[]).map(f => {
const sel = format === f.id;
return (
<div
key={f.id}
onClick={() => setFormat(f.id)}
className="px-3 py-2.5 rounded-lg mb-1 cursor-pointer"
style={{
background: sel ? 'var(--gd-red-soft)' : 'transparent',
border: `1px solid ${sel ? 'var(--gd-red-line)' : 'transparent'}`,
}}
>
<div className="text-[12.5px] font-medium">{f.name}</div>
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>{f.detail}</div>
</div>
);
})}
</div>
{format === 'gpx' && (
<div>
<SectionLabel>Include</SectionLabel>
<CheckRow label="Stop notes & descriptions" value={includeNotes} onChange={setIncludeNotes} />
<CheckRow label="Nearby places" value={includeNearby} onChange={setIncludeNearby} />
</div>
)}
</div>
<div className="flex-1 relative overflow-hidden" style={{ background: 'var(--gd-bg)' }}>
<div className="px-4 py-2.5 flex items-center gap-2.5 text-[11px]" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<span className="mono" style={{ color: 'var(--gd-text-3)' }}>{filename}</span>
<div className="flex-1" />
<span className="num" style={{ color: 'var(--gd-text-3)' }}>{allStops.length} waypoints</span>
</div>
<pre
className="m-0 p-4 overflow-auto mono text-[11px] leading-[1.5]"
style={{ color: 'var(--gd-text-2)', maxHeight: 420 }}
>
{content}
</pre>
</div>
</div>
</ModalShell>
);
}
// ─── Travel dates panel ──────────────────────────────────────────────────────
function TravelDatesPanel({
open, anchorRect, value, onChange, onClose, onApply,
}: {
open: boolean;
anchorRect: DOMRect | null;
value: TravelDates;
onChange: (v: TravelDates) => void;
onClose: () => void;
onApply: () => void;
}) {
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!open || !anchorRect) return null;
const panelWidth = 380;
const left = Math.max(12, Math.min(window.innerWidth - panelWidth - 12, anchorRect.right - panelWidth));
const top = anchorRect.bottom + 8;
const nights = nightsBetween(value.outbound, value.return);
const today = new Date().toISOString().slice(0, 10);
return (
<>
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
<div
className="fixed overflow-hidden"
style={{
zIndex: 9999,
left, top, width: panelWidth,
background: 'rgba(20,20,24,0.96)',
backdropFilter: 'blur(18px)',
border: '1px solid var(--gd-border-2)',
borderRadius: 14,
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
<div className="px-4 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div>
<div className="text-[13px] font-medium">Travel dates</div>
<div className="text-[11px]" style={{ color: 'var(--gd-text-3)' }}>
Sharpens crossing, ferry and hotel pricing
</div>
</div>
{nights != null && (
<div className="text-[11px] num px-2 py-0.5 rounded-full" style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}>
{nights} night{nights === 1 ? '' : 's'}
</div>
)}
</div>
<div className="px-4 py-3 space-y-3">
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5" style={{ color: 'var(--gd-text-3)' }}>Outbound</div>
<input
type="date"
min={today}
value={value.outbound || ''}
onChange={(e) => onChange({ ...value, outbound: e.target.value || null })}
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
/>
</div>
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
<span>Return</span>
{value.return && (
<button className="text-[10.5px] hover:underline" onClick={() => onChange({ ...value, return: null })}>
Make one-way
</button>
)}
</div>
<input
type="date"
min={value.outbound || today}
value={value.return || ''}
onChange={(e) => onChange({ ...value, return: e.target.value || null })}
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
/>
</div>
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
<span>Travellers</span>
<Users className="w-3 h-3" />
</div>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map(n => {
const selected = (value.travellers ?? 2) === n;
return (
<button
key={n}
onClick={() => onChange({ ...value, travellers: n })}
className="flex-1 h-9 rounded-lg text-[12.5px] num transition"
style={{
background: selected ? 'var(--gd-red)' : 'var(--gd-panel)',
border: `1px solid ${selected ? 'var(--gd-red)' : 'var(--gd-border)'}`,
color: selected ? '#fff' : 'var(--gd-text-2)',
}}
>
{n}
</button>
);
})}
</div>
</div>
</div>
<div className="px-4 py-3 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button
onClick={() => { onChange({ outbound: null, return: null, travellers: value.travellers }); }}
className="flex-1 h-9 text-[12px] rounded-lg"
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Clear
</button>
<button
onClick={onApply}
disabled={!value.outbound}
className="flex-[2] h-9 text-[12px] rounded-lg disabled:opacity-40"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
{value.outbound ? 'Apply & re-quote' : 'Pick an outbound date'}
</button>
</div>
</div>
</>
);
}
// ─── Crossing swap block ─────────────────────────────────────────────────────
function CrossingSwapBlock({ options, onPick }: { options: CrossingOption[]; onPick: (c: CrossingOption) => void }) {
if (options.length === 0) return null;
const current = options.find(o => o.isCurrent) || options[0];
return (
<div className="mt-2 rounded-xl overflow-hidden" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<Ship className="w-3.5 h-3.5" style={{ color: 'var(--gd-blue)' }} />
<div className="text-[10.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>
Pick your crossing — {options.length} option{options.length === 1 ? '' : 's'}
</div>
</div>
<div className="divide-y" style={{ borderColor: 'var(--gd-border)' }}>
{options.map(opt => {
const isPicked = opt.id === current.id;
const deltaMin = formatDelta(opt.detourMin, 'min');
const deltaKm = formatDelta(opt.detourKm, 'km');
return (
<button
key={opt.id}
onClick={() => !isPicked && onPick(opt)}
disabled={isPicked}
className="w-full text-left px-3 py-2.5 transition hover:bg-white/[0.03] disabled:cursor-default"
style={{
background: isPicked ? 'var(--gd-blue-soft, rgba(80,140,255,0.08))' : 'transparent',
borderTop: 'none',
borderBottom: '1px solid var(--gd-border)',
}}
>
<div className="flex items-center gap-2 mb-1">
<div className="text-[12.5px] font-medium flex-1" style={{ color: 'var(--gd-text)' }}>
{opt.operator}
</div>
{opt.badge && (
<span
className="text-[9.5px] font-semibold tracking-wide uppercase px-1.5 py-0.5 rounded"
style={{
background: isPicked ? 'var(--gd-blue, #3b82f6)' : 'var(--gd-panel-2)',
color: isPicked ? '#fff' : 'var(--gd-text-2)',
}}
>
{opt.badge}
</span>
)}
{isPicked && (
<span className="text-[9.5px] font-semibold uppercase px-1.5 py-0.5 rounded" style={{ background: 'var(--gd-blue, #3b82f6)', color: '#fff' }}>
Current
</span>
)}
</div>
<div className="text-[11px] mb-1.5" style={{ color: 'var(--gd-text-2)' }}>
{opt.fromPort} → {opt.toPort}
</div>
<div className="flex items-center gap-3 text-[11px] num" style={{ color: 'var(--gd-text-3)' }}>
<span><Clock className="w-3 h-3 inline mr-0.5" />{formatDuration(opt.durationMin)}</span>
<span><Euro className="w-3 h-3 inline mr-0.5" />€{Math.round(opt.priceEur)}</span>
{opt.frequency && <span className="truncate">· {opt.frequency}</span>}
</div>
{(deltaMin || deltaKm) && !isPicked && (
<div className="mt-1 text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
Trip impact: {deltaMin || '±0 min'} · {deltaKm || '±0 km'}
</div>
)}
{opt.bookingUrl && (
<a
href={opt.bookingUrl} target="_blank" rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="mt-1 inline-flex items-center gap-1 text-[10.5px] hover:underline"
style={{ color: 'var(--gd-blue, #5b8bff)' }}
>
Book <ExternalLink className="w-2.5 h-2.5" />
</a>
)}
</button>
);
})}
</div>
</div>
);
}
// ─── Vehicle selector panel ──────────────────────────────────────────────────
function VehicleSelectorPanel({
open, anchorRect, selected, onSelect, onClose,
}: {
open: boolean;
anchorRect: DOMRect | null;
selected: Vehicle;
onSelect: (v: Vehicle) => void;
onClose: () => void;
}) {
const [chargePct, setChargePct] = React.useState(80);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!open || !anchorRect) return null;
const panelWidth = 460;
const left = Math.max(12, Math.min(window.innerWidth - panelWidth - 12, anchorRect.right - panelWidth));
const top = anchorRect.bottom + 8;
const pick = (m: VehicleModel, t: VehicleTrim) => {
onSelect({
modelId: m.id, trimId: t.id, name: m.name, trim: t.name,
rangeKm: t.rangeKm, kw: t.kw, sec0to60: t.sec0to60, topKmh: t.topKmh, badge: t.badge,
});
onClose();
};
return (
<>
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
<div
className="fixed overflow-hidden"
style={{
zIndex: 9999,
left, top, width: panelWidth, maxHeight: '78vh',
background: 'rgba(20,20,24,0.96)',
backdropFilter: 'blur(18px)',
border: '1px solid var(--gd-border-2)',
borderRadius: 14,
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
display: 'flex', flexDirection: 'column',
}}
>
{/* Header with starting-charge slider */}
<div className="px-4 py-3.5 flex-shrink-0" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="flex items-center justify-between mb-2.5">
<div className="text-[11px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>
Starting charge
</div>
<div className="text-[12px] num" style={{ color: 'var(--gd-text)' }}>
{chargePct}% · <span className="num" style={{ color: 'var(--gd-green)' }}>
{Math.round(selected.rangeKm * chargePct / 100)} km
</span>
</div>
</div>
<div className="relative h-4">
<input
type="range" min={10} max={100} step={5} value={chargePct}
onChange={(e) => setChargePct(parseInt(e.target.value))}
className="absolute inset-0 w-full opacity-0 cursor-pointer z-10"
/>
<div className="absolute top-1.5 left-0 right-0 h-1 rounded" style={{ background: 'var(--gd-border)' }} />
<div className="absolute top-1.5 left-0 h-1 rounded" style={{ width: `${chargePct}%`, background: 'var(--gd-green)' }} />
<div
className="absolute top-0 w-4 h-4 rounded-full"
style={{ left: `calc(${chargePct}% - 8px)`, background: '#fff', border: '3px solid var(--gd-green)' }}
/>
</div>
</div>
{/* Model + trim list */}
<div className="overflow-y-auto px-2 py-2 flex-1 min-h-0">
{TESLA_MODELS.map(m => (
<ModelGroup
key={m.id}
model={m}
selected={selected}
chargePct={chargePct}
onPick={(t) => pick(m, t)}
/>
))}
</div>
</div>
</>
);
}
function ModelGroup({ model, selected, chargePct, onPick }: {
model: VehicleModel; selected: Vehicle; chargePct: number; onPick: (t: VehicleTrim) => void;
}) {
const isExpanded = model.id === selected.modelId;
const [open, setOpen] = React.useState(isExpanded);
return (
<div className="mb-1.5">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-white/[0.04] transition"
>
<div className="text-[14px] font-medium flex-1 text-left">{model.name}</div>
<div className="text-[11px]" style={{ color: 'var(--gd-text-3)' }}>{model.description}</div>
{open ? <ChevronUp className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-3)' }} /> : <ChevronDown className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-3)' }} />}
</button>
{open && (
<div className="pl-3 pr-1 pb-1 space-y-1">
{model.trims.map(t => {
const isSel = model.id === selected.modelId && t.id === selected.trimId;
const kmNow = Math.round(t.rangeKm * chargePct / 100);
return (
<button
key={t.id}
onClick={() => onPick(t)}
className="w-full text-left p-2.5 rounded-lg transition"
style={{
background: isSel ? 'var(--gd-red-soft)' : 'transparent',
border: `1px solid ${isSel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
}}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="text-[13px] font-medium">{t.name}</div>
{t.badge && (
<span
className="text-[9px] font-semibold tracking-wider px-1.5 py-0.5 rounded-full"
style={{
color: t.badge === 'Performance' ? 'var(--gd-amber)' : 'var(--gd-blue)',
border: `1px solid ${t.badge === 'Performance' ? 'rgba(251,191,36,0.4)' : 'rgba(96,165,250,0.4)'}`,
}}
>
{t.badge}
</span>
)}
<div className="flex-1" />
{isSel && (
<span className="text-[9.5px] font-semibold tracking-wider" style={{ color: 'var(--gd-red)' }}>SELECTED</span>
)}
</div>
<div className="flex gap-3.5 mt-1.5 text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
<span>{t.rangeKm} km full</span>
<span style={{ color: 'var(--gd-green)' }}>{kmNow} km now</span>
<span>0100 in {t.sec0to60}s</span>
<span>{t.kw} kW peak</span>
</div>
</button>
);
})}
</div>
)}
</div>
);
}
function CheckRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-2 py-1.5 text-[12px] cursor-pointer" style={{ color: 'var(--gd-text-2)' }}>
<div
onClick={(e) => { e.preventDefault(); onChange(!value); }}
className="w-4 h-4 rounded grid place-items-center"
style={{
border: `1.5px solid ${value ? 'var(--gd-text)' : 'var(--gd-border-2)'}`,
background: value ? 'var(--gd-text)' : 'transparent',
}}
>
{value && <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#0a0a0c" strokeWidth={3.5}><path d="M5 12l5 5 9-11" /></svg>}
</div>
<span>{label}</span>
</label>
);
}