f793b526aa
- 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.
3885 lines
168 KiB
TypeScript
3885 lines
168 KiB
TypeScript
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="© 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, '&').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<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>0–100 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>
|
||
);
|
||
}
|