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, type TeslaActiveRoute } from '../lib/tesla'; import { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock'; 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 = { 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(); 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 { 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 { if (typeof navigator === 'undefined' || !navigator.geolocation) return null; return new Promise((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 { 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 { 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 (
Something went wrong rendering the planner.
); } return this.props.children; } } // ─── Reusable bits ─────────────────────────────────────────────────────────── function ChipButton({ children, onClick, className = '' }: { children: React.ReactNode; onClick?: () => void; className?: string }) { return ( ); } function LegRow({ leg }: { leg: Leg | undefined }) { if (!leg) return null; return (
{formatKm(leg.distanceKm)} · {formatDuration(leg.durationMin)} drive
); } // ─── Skeleton shimmer ──────────────────────────────────────────────────────── function SkeletonRow({ widthPct = 100 }: { widthPct?: number }) { return (
); } // ─── Variant strip ─────────────────────────────────────────────────────────── const VARIANT_TONE: Record = { primary: 'var(--gd-red)', green: 'var(--gd-green)', blue: 'var(--gd-blue)', }; const VARIANT_ICON: Record = { 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 (
{variants.map(v => { const isSel = v.id === selected; const tone = VARIANT_TONE[v.tone]; const Icon = VARIANT_ICON[v.id]; return ( ); })}
); } function VStat({ label, value, highlight, tone }: { label: string; value: string; highlight?: boolean; tone?: string }) { return (
{label}
{value}
); } // ─── Day header (sticky) ───────────────────────────────────────────────────── function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: { dayNumber: number; title?: string; distanceKm: number; driveMin: number; chargeMin: number; dateLabel?: string; }) { return (
DAY {dayNumber}
{dateLabel &&
· {dateLabel}
}
{title &&
{title}
}
{formatKm(distanceKm)} · {formatDuration(driveMin)} drive · {formatDuration(chargeMin)} charging
); } // ─── 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 ( ); } // ─── 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 (
{items.map((n, i) => (
{AMENITY_ICONS[n.icon] || '•'}
{n.name}
{n.detail}
))}
); } 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 (
{open && others.length > 0 && (
{others.map(o => (
e.stopPropagation()} className="p-2.5 rounded-md flex items-center gap-2.5 transition hover:bg-white/[0.04]" >
{o.name} {o.badge && ( {o.badge} )}
{o.stalls} stalls · {o.kw} kW · €{o.pricePerKwh.toFixed(2)}/kWh {o.detourMin > 0 ? ` · +${o.detourMin} min` : ''} {o.network && o.network !== 'Tesla' ? ` · ${o.network}` : ''}
))}
)}
); } 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 (
{stop.description && (
{stop.description}
)} {isCharge && (arrive != null || charge != null) && (
)} {isCharge && chargers.length > 0 && (
Charger toast.success(`Picked ${c.name}`, { description: `${c.kw} kW · €${c.pricePerKwh.toFixed(2)}/kWh` })} />
)} {isCrossing && crossings.length > 0 && (
Crossing onPickCrossing?.(c)} />
)} {nearby.length > 0 && (
While here
{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 ( ); })}
{filteredNearby.length > 0 ? ( ) : (
Nothing in this category nearby.
)}
)} {amenities.length > 0 && (
Amenities
{amenities.map(a => (
{AMENITY_ICONS[a] || '•'} {a.replace(/-/g, ' ')}
))}
)} {alts.length > 0 && (
{alts.length} location alternative{alts.length === 1 ? '' : 's'}
{alts.map(alt => ( onSwap(alt)} /> ))}
)} {stop.notes && (
{stop.notes}
)}
{canSendToNav && ( )}
); } function SectionLabel({ children }: { children: React.ReactNode }) { return (
{children}
); } 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 (
{label}
{value}
); } // ─── 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 (
{label}
{detail}
); } // ─── 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 (
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, }} >
{stop.name}
{isCharge && typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 && (
{stop.chargeMinutes}m {typeof stop.estArrivalBattery === 'number' && ( <> · arrive {stop.estArrivalBattery}% )}
)} {isSleep && (
overnight
)} {isCrossingStop && currentCrossing && (
€{Math.round(currentCrossing.priceEur)} · {formatDuration(currentCrossing.durationMin)}
)}
{stop.combo && (
{stop.combo}
)}
{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')}
{!active && stop.description && (
{stop.description}
)} {active && }
); } // ─── 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: `
${active ? `
` : ''}
`, }); } // ─── 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['status']; teslaState: ReturnType['state']; onConnectTesla: () => void; onDisconnectTesla: () => void; inCar: boolean; }) { // Tesla connected → we know the car (and where it is), so the vehicle picker // and the manual From input are both dead weight. The OD strip collapses to // a destination-focused planner (see ConnectedTripStrip). const hideVehicleChip = !!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 (
{/* Brand */}
Grok Drive
BETA
{/* Origin → Destination — collapsed to a single destination-focused strip when Tesla is connected, because the origin is just "where the car is". */} {teslaStatus?.connected ? ( ) : (
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)' }} />
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)' }} />
)}
{/* Chat composer with chips */}
{chips.length > 0 && (
{chips.map((c, i) => (
{c}
))}
)} 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)' }} />
{/* Vehicle chip — opens trim panel. Hidden when in-car with Tesla connected (we already know the car). */} {!hideVehicleChip && ( )} {teslaStatus?.available && ( teslaStatus.connected ? ( ) : ( ) )} {/* Live charging widget — only when plugged in. */} {teslaState?.chargingState === 'Charging' && ( )} {!hideGpxChip && ( onOpenGpx()}> Export )} {!inCar && ( toast('Shareable link copied')}> Share )} {grokStatus.label && (
{grokStatus.label}
)}
); } // Compact destination strip used when Tesla is connected. Origin is implicit // (the car's GPS), so we only need to know where the user is going. function ConnectedTripStrip({ activeRoute, destination, onDestinationChange, onCommit, }: { activeRoute: TeslaActiveRoute | null; destination: string; onDestinationChange: (v: string) => void; onCommit: () => void; }) { // If Tesla nav already has a destination AND it doesn't match what the // planner's currently using, surface it as a one-tap apply suggestion. const navDest = activeRoute?.destination; const navMatches = !!navDest && destination.trim().toLowerCase() === navDest.toLowerCase(); const showSuggestion = !!navDest && !navMatches; return (
From car
onDestinationChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onCommit(); } }} onBlur={onCommit} placeholder={navDest ? `Going to ${navDest} (or type)` : 'Where to?'} className="bg-transparent border-none outline-none text-[13px] w-full" style={{ color: 'var(--gd-text)' }} /> {showSuggestion && ( )}
); } function ChargingWidget({ kw, minutesToTarget, battery }: { kw: number | null; minutesToTarget: number | null; battery: number | null; }) { return (
{kw != null ? `${Math.round(kw)} kW` : 'Charging'}
{battery != null && minutesToTarget != null ? `${battery}% · ${minutesToTarget}m left` : minutesToTarget != null ? `${minutesToTarget}m left` : battery != null ? `${battery}%` : '—'}
); } function MockTeslaIndicator() { const [scenario, setScenarioState] = React.useState(getMockScenario()); if (!scenario) return null; const cycle = () => { const next = scenario === 'parked' ? 'driving' : scenario === 'driving' ? 'charging' : scenario === 'charging' ? 'asleep' : scenario === 'asleep' ? 'parked' : 'parked'; setMockScenario(next); resetMockDrive(); setScenarioState(next); // Hard reload so all hooks pick up the new scenario cleanly. window.location.reload(); }; const disable = () => { setMockScenario(null); window.location.reload(); }; return (
MOCK
); } // ─── 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]); // When Tesla is connected and has an active in-car nav destination, and the // user hasn't typed their own destination yet, adopt the Tesla nav target. const autoDestRef = React.useRef(false); React.useEffect(() => { if (!teslaConnected || autoDestRef.current) return; const nav = tesla.state?.activeRoute?.destination; if (!nav) return; if (destination.trim()) return; // user already chose one autoDestRef.current = true; setDestination(nav); }, [teslaConnected, tesla.state?.activeRoute?.destination]); // 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([]); const [thinking, setThinking] = useState(false); const [thinkingMessage, setThinkingMessage] = useState(''); const [itinerary, setItinerary] = useState(EMPTY_ITINERARY); const [vehicle, setVehicle] = useState(DEFAULT_VEHICLE); const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false); const [vehicleAnchor, setVehicleAnchor] = useState(null); const [grokStatus, setGrokStatus] = useState<{ label?: string }>({ label: 'Local Heavy' }); const [legs, setLegs] = useState([]); const [activeStopId, setActiveStopId] = useState(null); const [hoverStopId, setHoverStopId] = useState(null); const [origin, setOrigin] = useState(''); const [destination, setDestination] = useState(''); const [travelDates, setTravelDates] = useState({ outbound: null, return: null, travellers: 2 }); const [datePickerOpen, setDatePickerOpen] = useState(false); const [dateAnchor, setDateAnchor] = useState(null); const [ownerLoginOpen, setOwnerLoginOpen] = useState(false); // After successful owner login, fire this pending action. const ownerLoginThenRef = React.useRef void)>(null); const lastODSent = React.useRef<{ from: string; to: string } | null>(null); const [variants, setVariants] = useState([]); const [selectedVariant, setSelectedVariant] = useState('fast'); const [variantSwitching, setVariantSwitching] = useState(false); const [variantCache, setVariantCache] = useState>({}); const [showCompare, setShowCompare] = useState(false); const [draggingId, setDraggingId] = useState(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(); 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>(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(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(''); 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) => { 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']; // Driving-mode trigger: car is moving (or in a non-Park gear). Also enabled // via ?drivingMode=1 for development. const forceDrivingMode = React.useMemo(() => { if (typeof window === 'undefined') return false; return new URLSearchParams(window.location.search).get('drivingMode') === '1'; }, []); const isDriving = forceDrivingMode || ( !!tesla.state?.shiftState && tesla.state.shiftState !== 'P' ); // Closest planned stop to the car's current position, used by driving mode. const nextStop = React.useMemo(() => { if (!isDriving || !tesla.state?.lat || !tesla.state?.lng) return null; let best: { stop: Stop; km: number } | null = null; for (const s of allStops) { const km = haversineKm( { lat: tesla.state.lat, lng: tesla.state.lng }, { lat: s.lat, lng: s.lng }, ); if (best === null || km < best.km) best = { stop: s, km }; } return best; }, [isDriving, tesla.state?.lat, tesla.state?.lng, allStops]); return ( {isDriving && ( { if (!nextStop) return; const ok = await sendToTeslaNav({ lat: nextStop.stop.lat, lng: nextStop.stop.lng, name: nextStop.stop.name }); if (ok) toast.success(`Sent ${nextStop.stop.name} to Tesla nav`); else toast.error('Could not send'); }} onExit={() => { // Force-exit driving mode (debug). Strips ?drivingMode=1 if present. const url = new URL(window.location.href); url.searchParams.delete('drivingMode'); window.history.replaceState({}, '', url.toString()); window.location.reload(); }} /> )}
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 && ( setShowCompare(s => !s)} /> )} {/* Body: map left, rail right */}
{/* Map */}
{allStops.map(stop => { const meta = stopMeta(stop.type); const isActive = stop.id === activeStopId; const isHover = stop.id === hoverStopId; return ( setActiveStopId(stop.id), mouseover: () => setHoverStopId(stop.id), mouseout: () => setHoverStopId(null), }} >
{stop.name}
{stop.combo && (
{stop.combo}
)} {stop.description &&
{stop.description}
}
); })} {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) => ( )); })} {legs.map((leg, i) => ( ))}
{/* Map legend (variants when comparing, else stop types) */} {showCompare ? (
{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 (
{v.label}
); })}
) : (
Charge
Sleep
See
)} {/* Refinements overlay */} {chips.length > 0 && (
Refinements applied · {chips.length}
{chips.map((c, i) => ( {c} ))}
)} {/* Empty state overlay */} {allStops.length === 0 && !thinking && (
Where to?
Use the chat above to describe your trip. Try one of the quick prompts in the stops rail.
)} {/* Thinking overlay on the map (when Grok is planning but nothing rendered yet) */} {allStops.length === 0 && thinking && (
Grok is planning your route
{thinkingMessage ? (
“{thinkingMessage}”
) : (
)}
Stops will appear as soon as they're chosen
)}
{/* Stops rail */}
{modal?.kind === 'customise' && ( 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' && ( setModal(null)} onInsert={(place) => insertDetour(place, modal.afterStopId)} /> )} {modal?.kind === 'gpx' && ( setModal(null)} /> )} setVehiclePanelOpen(false)} /> setDatePickerOpen(false)} onApply={() => { setDatePickerOpen(false); if (allStops.length > 0) { sendMessage(`Update the itinerary for travel dates ${formatDateRange(travelDates.outbound, travelDates.return)}`, { silent: true }); } }} /> setOwnerLoginOpen(false)} onSuccess={async () => { setOwnerLoginOpen(false); await tesla.refreshStatus(); const fn = ownerLoginThenRef.current; ownerLoginThenRef.current = null; if (fn) fn(); }} /> ); } // ─── Driving mode ──────────────────────────────────────────────────────────── // Full-screen overlay when the car is moving. Huge typography, minimal chrome, // only one possible action (Send next stop to nav). function DrivingMode({ tesla, nextStop, vehicle, onSendToNav, onExit }: { tesla: ReturnType; nextStop: { stop: Stop; km: number } | null; vehicle: Vehicle; onSendToNav: () => void; onExit: () => void; }) { const state = tesla.state; const battery = state?.battery ?? null; const rangeKm = state?.rangeKm ?? null; const speed = state?.speedKmh ?? null; const distanceKm = nextStop?.km ?? null; const reqPctRough = distanceKm != null ? Math.max(5, Math.round((distanceKm / Math.max(50, vehicle.rangeKm)) * 100)) : null; const enough = battery != null && reqPctRough != null ? battery >= reqPctRough + 8 : null; // ETA roughly distance ÷ assumed 95 km/h motorway, expressed as a clock time. const etaText = React.useMemo(() => { if (distanceKm == null) return null; const avgKmh = speed && speed > 30 ? Math.max(60, speed) : 95; const minutes = Math.round((distanceKm / avgKmh) * 60); const arrive = new Date(Date.now() + minutes * 60_000); return `${arrive.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} · ${minutes}m`; }, [distanceKm, speed]); const meta = nextStop ? stopMeta(nextStop.stop.type) : null; const Icon = meta?.icon ?? MapPin; return (
{/* Top strip — battery + speed + exit */}
{battery != null ? `${battery}%` : '—'}
Range
{rangeKm != null ? `${rangeKm} km` : '—'}
Driving · {state?.shiftState ?? 'D'}
{speed ?? 0}
km/h
{/* Centre — next stop hero */}
{nextStop ? ( <>
Next stop
{nextStop.stop.name}
Distance
{Math.round(distanceKm ?? 0)} km
{etaText && (
Arrive
{etaText}
)}
{enough != null && (
{enough ? `Enough battery — ~${reqPctRough}% needed` : `Low battery — need ~${reqPctRough}% to reach this stop`}
)} ) : (
No trip loaded
Plan a trip while parked and it'll pick up from here.
)}
{/* Bottom — charging status if plugged in */} {state?.chargingState === 'Charging' && (
Charging
{state.chargerPowerKw ?? '—'} kW {state.timeToFullCharge != null && ( · {Math.round(state.timeToFullCharge * 60)}m to target )}
)}
); } 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 (
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)', }} >
Owner login required
The Tesla integration is restricted to the deploying user. Enter the OWNER_SECRET set in the deploy environment.
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)' }} />
); } // ─── 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 (
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 && (
{title}
{subtitle &&
{subtitle}
}
)}
{children}
{footer && (
{footer}
)}
); } // ─── 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) => 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(initialTab); const [chargeMinutes, setChargeMinutes] = React.useState(stop.chargeMinutes || 30); const [durationMin, setDurationMin] = React.useState(stop.durationMin || stop.chargeMinutes || 30); const [pickedNearby, setPickedNearby] = React.useState>(new Set()); const [chosenChargerId, setChosenChargerId] = React.useState(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 (
Total stop: {durationMin + pickedNearby.size * 8} min
} >
{/* Tab rail */}
{tabs.filter(t => t.show).map(t => { const TI = t.icon; const active = tab === t.id; return ( ); })}
{/* Body */}
{tab === 'charger' && ( )} {tab === 'overnight' && ( )} {tab === 'duration' && ( )} {tab === 'things' && ( { const next = new Set(pickedNearby); if (next.has(name)) next.delete(name); else next.add(name); setPickedNearby(next); }} /> )} {tab === 'detour' && }
); } function CustomiseChargerTab({ options, chosenId, onChoose }: { options: ChargerOption[]; chosenId: string; onChoose: (id: string) => void; }) { if (options.length === 0) { return ; } return (
Choose a charger
{options.map(o => { const sel = o.id === chosenId; return (
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)'}`, }} >
{sel &&
}
{o.name}
{o.badge && ( {o.badge} )}
{o.kw} kW · {o.stalls} stalls €{o.pricePerKwh.toFixed(2)}/kWh {o.detourMin > 0 ? ( +{o.detourMin} min detour ) : ( On route )} {o.network && {o.network}}
); })}
); } function CustomiseOvernightTab({ alternatives, currentName }: { alternatives: AlternativeStop[]; currentName: string }) { if (alternatives.length === 0) { return ; } return (
Choose where to sleep
{currentName}
Currently selected
Current
{alternatives.map(a => (
{a.name}
{a.reason || a.description}
))}
); } 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 (
How long here?
{value}
minutes
{isCharge && arrivePct != null && (
arrive {arrivePct}% · leave {leavePct}%
)}
onChange(parseInt(e.target.value))} className="absolute inset-0 w-full h-10 opacity-0 z-10 cursor-pointer" />
{[15, 30, 45, 60, 90, 120].map(t => (
{t}m
))}
Presets
{[ { 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 => ( ))}
); } function CustomiseThingsTab({ nearby, picked, onTogglePick }: { nearby: NearbyPlace[]; picked: Set; onTogglePick: (name: string) => void; }) { if (nearby.length === 0) { return ; } return (
Pick what you want to do here
{nearby.map((n, i) => { const sel = picked.has(n.name); return (
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)'}`, }} >
{AMENITY_ICONS[n.icon] || '📍'}
{n.name}
{n.detail}
{sel && }
); })}
); } function CustomiseDetourTab() { return (
Add a detour from this stop
Search is mocked — use the "Add stop" button in the rail header to insert a detour from the popular-places list.
); } function EmptyTab({ text }: { text: string }) { return (
{text}
); } // ─── 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 (
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)', }} >
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)' }} /> esc
{filtered.length === 0 ? (
No matches in the popular-detours list. Real search coming soon.
) : ( filtered.map(r => (
{r.name}
{r.description} · +{r.detourMin} min
)) )}
); } // ─── 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 = ` ${escapeXml(allStops[0]?.name || 'Trip')} → ${escapeXml(allStops[allStops.length - 1]?.name || 'Destination')} ${itinerary.summary.totalDistanceKm} km · ${itinerary.summary.estDriveHours}h drive · ${itinerary.summary.estChargeHours}h charging Grok Drive route `; const points = allStops.map(s => { const inner: string[] = [ ` ${escapeXml(s.name)}`, ` ${escapeXml(s.type)}`, ]; if (includeNotes && s.description) inner.push(` ${escapeXml(s.description)}`); if (includeNearby && s.nearby && s.nearby.length > 0) { inner.push(` Nearby: ${escapeXml(s.nearby.slice(0, 3).map(n => n.name).join(', '))}`); } return ` \n${inner.join('\n')}\n `; }).join('\n'); return `${header}\n${points}\n \n \n`; } function generateKml(itinerary: Itinerary): string { const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number'); const placemarks = allStops.map(s => ` ${escapeXml(s.name)} ${escapeXml(s.description || s.type)} ${s.lng.toFixed(5)},${s.lat.toFixed(5)},0 `).join('\n'); const lineCoords = allStops.map(s => `${s.lng.toFixed(5)},${s.lat.toFixed(5)},0`).join(' '); return ` Grok Drive route ${placemarks} Route${lineCoords} `; } 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, '&').replace(//g, '>').replace(/"/g, '"'); } 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('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 (
} >
Plan a trip first — there's nothing to export yet.
); } return (
{content.split('\n').length} lines · ~{(content.length / 1024).toFixed(1)} KB
} >
Format {([ { 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 (
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'}`, }} >
{f.name}
{f.detail}
); })}
{format === 'gpx' && (
Include
)}
{filename}
{allStops.length} waypoints
            {content}
          
); } // ─── 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 ( <>
Travel dates
Sharpens crossing, ferry and hotel pricing
{nights != null && (
{nights} night{nights === 1 ? '' : 's'}
)}
Outbound
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' }} />
Return {value.return && ( )}
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' }} />
Travellers
{[1, 2, 3, 4, 5].map(n => { const selected = (value.travellers ?? 2) === n; return ( ); })}
); } // ─── 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 (
Pick your crossing — {options.length} option{options.length === 1 ? '' : 's'}
{options.map(opt => { const isPicked = opt.id === current.id; const deltaMin = formatDelta(opt.detourMin, 'min'); const deltaKm = formatDelta(opt.detourKm, 'km'); return ( ); })}
); } // ─── 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 ( <>
{/* Header with starting-charge slider */}
Starting charge
{chargePct}% · {Math.round(selected.rangeKm * chargePct / 100)} km
setChargePct(parseInt(e.target.value))} className="absolute inset-0 w-full opacity-0 cursor-pointer z-10" />
{/* Model + trim list */}
{TESLA_MODELS.map(m => ( pick(m, t)} /> ))}
); } 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 (
{open && (
{model.trims.map(t => { const isSel = model.id === selected.modelId && t.id === selected.trimId; const kmNow = Math.round(t.rangeKm * chargePct / 100); return ( ); })}
)}
); } function CheckRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) { return ( ); }