diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index ee08f99..e41b5a6 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -1,11 +1,15 @@ import React, { useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Send, MapPin, BatteryCharging, Clock, Share2, Download, Car, Zap, AlertTriangle, ArrowLeftRight, ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; -import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet'; +import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; 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, +} from 'lucide-react'; -// Fix Leaflet default icons +// 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', @@ -13,14 +17,8 @@ L.Icon.Default.mergeOptions({ shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', }); -const VEHICLES = [ - { name: 'Model Y Long Range', rangeKm: 514, efficiency: 165 }, - { name: 'Model 3 Highland LR', rangeKm: 549, efficiency: 155 }, - { name: 'Model S Long Range', rangeKm: 634, efficiency: 175 }, - { name: 'Model Y RWD (EU)', rangeKm: 455, efficiency: 158 }, -]; - -type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom'; +// ─── Types ─────────────────────────────────────────────────────────────────── +type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel'; interface AlternativeStop { id: string; @@ -35,9 +33,9 @@ interface AlternativeStop { priceLevel?: number; chargeMinutes?: number; durationMin?: number; - deltaKm?: number; // estimated change vs the currently-chosen stop (negative = saves distance) - deltaMin?: number; // estimated change in drive minutes vs currently-chosen - reason?: string; // why this is a worthwhile alternative + deltaKm?: number; + deltaMin?: number; + reason?: string; } interface Stop { @@ -72,48 +70,67 @@ interface Itinerary { }; } +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']; +const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel']; -const AMENITY_ICONS: Record = { - restaurant: '🍽️', - cafe: '☕', - 'fast-food': '🍔', - supermarket: '🛒', - toilets: '🚻', - shopping: '🛍️', - wifi: '📶', - playground: '🧒', - 'ev-charging': '⚡', - 'destination-charging': '🔌', - hotel: '🛏️', - coffee: '☕', - viewpoint: '🌄', - museum: '🏛️', - park: '🌳', - beach: '🏖️', - gym: '🏋️', - pool: '🏊', -}; - -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", +const VEHICLES = [ + { name: 'Model Y Long Range', trim: 'Long Range AWD', rangeKm: 514, efficiency: 165 }, + { name: 'Model 3 Highland LR', trim: 'Long Range', rangeKm: 549, efficiency: 155 }, + { name: 'Model S Long Range', trim: 'Long Range', rangeKm: 634, efficiency: 175 }, + { name: 'Model Y RWD (EU)', trim: 'Standard Range', rangeKm: 455, efficiency: 158 }, ]; -// Simple in-memory geocache +const QUICK_PROMPTS = [ + 'Plan a 2-day trip from London to Edinburgh in my Model Y', + 'I want to drive from Amsterdam to Munich', + 'Help me plan a scenic route from Paris to the Alps', + 'Best way from Glasgow to London avoiding motorways', +]; + +// ─── Amenity icon map (centralised) ────────────────────────────────────────── +const AMENITY_ICONS: Record = { + restaurant: '🍽️', cafe: '☕', 'fast-food': '🍔', supermarket: '🛒', + toilets: '🚻', shopping: '🛍️', wifi: '📶', playground: '🧒', + 'ev-charging': '⚡', 'destination-charging': '🔌', hotel: '🛏️', + coffee: '☕', viewpoint: '🌄', museum: '🏛️', park: '🌳', + beach: '🏖️', gym: '🏋️', pool: '🏊', +}; + +// ─── Stop meta (icon + accent colour per type) ─────────────────────────────── +function stopMeta(type: StopType): { icon: React.ComponentType<{ className?: string; size?: number }>; 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 'supercharger': + case 'destination-charger': return { icon: Zap, color: '#4ade80' }; + case 'hotel': return { icon: Bed, color: '#60a5fa' }; + case 'restaurant': + case 'cafe': return { icon: Utensils, color: '#fbbf24' }; + case 'attraction': + case 'viewpoint': return { icon: Camera, color: '#c084fc' }; + default: return { icon: MapPin, color: '#9ca3af' }; + } +} + +// ─── Geocoding (Nominatim) ─────────────────────────────────────────────────── const geocodeCache = new Map(); async function geocodeLocation(query: string): Promise<{ lat: number; lng: number } | null> { const cacheKey = query.toLowerCase().trim(); if (geocodeCache.has(cacheKey)) return geocodeCache.get(cacheKey)!; - try { const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`; const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } }); @@ -121,39 +138,34 @@ async function geocodeLocation(query: string): Promise<{ lat: number; lng: numbe 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)); // be nice to Nominatim + await new Promise(r => setTimeout(r, 1100)); return result; } - } catch (e) { + } catch { console.warn('[TeslaTrip] Geocoding failed for', query); } return null; } -// Very forgiving sanitization + geocoding +// ─── Itinerary normalization (preserves all enrichment fields) ─────────────── async function normalizeAndSanitizeItinerary(raw: any): Promise { if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY; - const normalizedDays: any[] = []; for (const day of raw.days) { if (!day) continue; - let rawStops: any[] = []; if (Array.isArray(day.stops)) rawStops = day.stops; else if (Array.isArray(day.chargeStops)) rawStops = day.chargeStops; else if (Array.isArray(raw.pointsOfInterest)) rawStops = raw.pointsOfInterest; const validStops: any[] = []; - for (const s of rawStops) { if (!s) continue; - let name = s.name || s.location || s; + 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; } @@ -163,15 +175,13 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { const cleanAlts: AlternativeStop[] = rawAlts .map((a: any): AlternativeStop | null => { if (!a || typeof a.name !== 'string') return null; - const altLat = typeof a.lat === 'number' ? a.lat : null; - const altLng = typeof a.lng === 'number' ? a.lng : null; - if (altLat === null || altLng === null) 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: altLat, - lng: altLng, + 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, @@ -186,7 +196,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { }) .filter((a: AlternativeStop | null): a is AlternativeStop => a !== null); - const sharedFields = { + const shared = { estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, durationMin: s.durationMin, @@ -198,24 +208,15 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { notes: s.notes, alternatives: cleanAlts.length > 0 ? cleanAlts : undefined, }; - const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom'; - if (lat === null || lng === null) { - validStops.push({ - id: s.id || `text-${Date.now()}-${Math.random()}`, - name, type: resolvedType, lat: null, lng: null, - day: day.day || 1, order: s.order || validStops.length + 1, - ...sharedFields, - }); - continue; - } - validStops.push({ id: s.id || `stop-${Date.now()}-${Math.random()}`, name, type: resolvedType, - lat, lng, day: day.day || 1, order: s.order || validStops.length + 1, - ...sharedFields, + lat: lat ?? null, lng: lng ?? null, + day: day.day || 1, + order: s.order || validStops.length + 1, + ...shared, }); } @@ -223,12 +224,12 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { 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), + stops: validStops.sort((a, b) => a.order - b.order), }); } } - const sortedDays = normalizedDays.sort((a,b) => a.day - b.day); + const sortedDays = normalizedDays.sort((a, b) => a.day - b.day); const allStops = sortedDays.flatMap(d => d.stops); return { @@ -246,35 +247,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { }; } -// Simple Error Boundary -class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { - constructor(props: any) { super(props); this.state = { hasError: false }; } - static getDerivedStateFromError() { return { hasError: true }; } - componentDidCatch(error: Error) { console.error('[TeslaTrip] ErrorBoundary caught:', error); } - render() { - if (this.state.hasError) { - return ( -
-
- -
Something went wrong rendering the map/itinerary.
-
Check console for details.
-
-
- ); - } - return this.props.children; - } -} - -interface Leg { - geometry: [number, number][]; - distanceKm: number | null; - durationMin: number | null; - fromId: string; - toId: 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; @@ -284,7 +257,6 @@ function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: num return 2 * R * Math.asin(Math.sqrt(h)); } -// Fetch actual road route using OSRM (free, no key); falls back to straight line + great-circle distance async function getRoadLeg(from: Stop, to: Stop): Promise { try { const url = `https://router.project-osrm.org/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`; @@ -296,20 +268,18 @@ async function getRoadLeg(from: Stop, to: Stop): Promise { 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, + fromId: from.id, toId: to.id, }; } - } catch (e) { + } 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, // assume ~80 km/h average if OSRM unavailable - fromId: from.id, - toId: to.id, + durationMin: (dist / 80) * 60, + fromId: from.id, toId: to.id, }; } @@ -324,20 +294,9 @@ function formatDuration(min: number | null): string { function formatKm(km: number | null): string { if (km == null || !Number.isFinite(km)) return '–'; - return `${Math.round(km)} km`; + return `${Math.round(km).toLocaleString()} km`; } -const STOP_DOT: Record = { - supercharger: 'bg-[#E82127]', - 'destination-charger': 'bg-rose-400', - hotel: 'bg-blue-500', - attraction: 'bg-amber-400', - restaurant: 'bg-emerald-400', - cafe: 'bg-amber-200', - viewpoint: 'bg-purple-400', - custom: 'bg-white/60', -}; - function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | null { if (typeof value !== 'number' || !Number.isFinite(value)) return null; const rounded = Math.round(value); @@ -345,492 +304,875 @@ function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | nu return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`; } -function LegPill({ leg }: { leg: Leg | undefined }) { +// ─── Error Boundary ────────────────────────────────────────────────────────── +class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { + constructor(props: any) { super(props); this.state = { hasError: false }; } + static getDerivedStateFromError() { return { hasError: true }; } + componentDidCatch(error: Error) { console.error('[TeslaTrip] ErrorBoundary caught:', error); } + render() { + if (this.state.hasError) { + return ( +
+
+ +
Something went wrong rendering the planner.
+
+
+ ); + } + return this.props.children; + } +} + +// ─── Reusable bits ─────────────────────────────────────────────────────────── +function ChipButton({ children, onClick, className = '' }: { children: React.ReactNode; onClick?: () => void; className?: string }) { + return ( + + ); +} + +function LegRow({ leg }: { leg: Leg | undefined }) { if (!leg) return null; return ( -
-
- - {formatKm(leg.distanceKm)} · {formatDuration(leg.durationMin)} drive +
+ + {formatKm(leg.distanceKm)} + · + {formatDuration(leg.durationMin)} drive +
+ ); +} + +// ─── Day header (sticky) ───────────────────────────────────────────────────── +function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: { + dayNumber: number; + title?: string; + distanceKm: number; + driveMin: number; + chargeMin: number; + dateLabel?: string; +}) { + return ( +
+
+
+ DAY {dayNumber} +
+ {dateLabel &&
· {dateLabel}
} +
+ {title &&
{title}
} +
+ {formatKm(distanceKm)} · {formatDuration(driveMin)} drive · {formatDuration(chargeMin)} charging
); } +// ─── Alternative row (used inside expanded stop) ───────────────────────────── function AlternativeRow({ alt, onSwap }: { alt: AlternativeStop; onSwap: () => void }) { const km = formatDelta(alt.deltaKm, 'km'); const min = formatDelta(alt.deltaMin, 'min'); const isFaster = typeof alt.deltaMin === 'number' && alt.deltaMin < 0; const isLonger = typeof alt.deltaMin === 'number' && alt.deltaMin > 0; + const deltaColor = isLonger ? 'var(--gd-amber)' : isFaster ? 'var(--gd-green)' : 'var(--gd-text-3)'; return ( ); } -function StopCard({ stop, onRemove, onSwap }: { stop: Stop; onRemove: () => void; onSwap: (alt: AlternativeStop) => void }) { - const [showAlts, setShowAlts] = React.useState(false); - const amenities = (stop.amenities || []).slice(0, 6); +// ─── Expanded stop body (charger swap, stats, alts, actions) ───────────────── +function StopExpansion({ stop, onSwap, onRemove }: { + stop: Stop; + onSwap: (alt: AlternativeStop) => void; + onRemove: () => void; +}) { + const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; + 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 hasAlts = alts.length > 0; + return ( -
-
-
-
-
-
{stop.name}
- {stop.combo && ( -
- {stop.combo} -
- )} - {stop.description && ( -
{stop.description}
- )} -
- {typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 && ⚡ {stop.chargeMinutes}m charge} - {typeof stop.durationMin === 'number' && stop.durationMin > 0 && ⏱ {stop.durationMin}m stop} - {typeof stop.estArrivalBattery === 'number' && 🔋 {stop.estArrivalBattery}%} - {stop.cuisine && 🍽️ {stop.cuisine}} - {typeof stop.priceLevel === 'number' && {'£'.repeat(Math.min(4, Math.max(1, stop.priceLevel)))}} -
- {amenities.length > 0 && ( -
- {amenities.map(a => ( - {AMENITY_ICONS[a] || '•'} - ))} -
- )} - {typeof stop.lat !== 'number' && ( -
Location not yet on map
- )} - {hasAlts && ( - - )} + {AMENITY_ICONS[a] || '•'} + {a.replace(/-/g, ' ')} +
+ ))}
- +
- {showAlts && hasAlts && ( -
-
Alternatives
- {alts.map(alt => ( - { onSwap(alt); setShowAlts(false); }} /> +
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ExpStat({ label, value, tone = 'text' }: { label: string; value: string; tone?: 'green' | 'amber' | 'text' }) { + const color = tone === 'green' ? 'var(--gd-green)' : tone === 'amber' ? 'var(--gd-amber)' : 'var(--gd-text)'; + return ( +
+
{label}
+
{value}
+
+ ); +} + +// ─── Stop card (icon-led) ──────────────────────────────────────────────────── +function StopCard({ stop, active, hover, onSelect, onHover, onSwap, onRemove }: { + stop: Stop; + active: boolean; + hover: boolean; + onSelect: () => void; + onHover: (h: boolean) => void; + onSwap: (alt: AlternativeStop) => void; + onRemove: () => 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 isTunnel = stop.type === 'tunnel'; + + return ( +
onHover(true)} + onMouseLeave={() => onHover(false)} + 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', + }} + > +
+
+ +
+
+
+
{stop.name}
+ {isCharge && typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 && ( +
+ {stop.chargeMinutes}m + {typeof stop.estArrivalBattery === 'number' && ( + <> + · + arrive {stop.estArrivalBattery}% + + )} +
+ )} + {isSleep && ( +
overnight
+ )} + {isTunnel && ( +
£89 · 35m
+ )} +
+ {stop.combo && ( +
+ {stop.combo} +
+ )} +
+ {stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : 'Stop')} +
+ + {!active && stop.description && ( +
+ {stop.description} +
+ )} + + {active && } +
+
+
+ ); +} + +// ─── Map controller: fit bounds when stops change ──────────────────────────── +function MapAutoFit({ stops }: { stops: Stop[] }) { + const map = useMap(); + React.useEffect(() => { + const valid = stops.filter(s => typeof s.lat === 'number' && typeof s.lng === 'number'); + if (valid.length < 2) return; + const bounds = L.latLngBounds(valid.map(s => [s.lat, s.lng] as [number, number])); + map.fitBounds(bounds, { padding: [60, 60], maxZoom: 8 }); + }, [stops, map]); + return null; +} + +function MapFlyTo({ stop }: { stop: Stop | null }) { + const map = useMap(); + React.useEffect(() => { + if (!stop || typeof stop.lat !== 'number') return; + map.flyTo([stop.lat, stop.lng], Math.max(map.getZoom(), 7), { duration: 0.6 }); + }, [stop, map]); + return null; +} + +// ─── Pin marker (svg-style coloured dot via Leaflet divIcon) ───────────────── +function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon { + const size = active ? 28 : hover ? 22 : 18; + const dot = active ? 10 : hover ? 8 : 7; + return L.divIcon({ + className: 'gd-pin', + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + html: ` +
+ ${active ? `
` : ''} +
+
`, + }); +} + +// ─── Top bar ───────────────────────────────────────────────────────────────── +function TopBar({ + origin, destination, onOriginChange, onDestinationChange, + chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, + vehicle, onVehicleChange, grokStatus, +}: { + origin: string; destination: string; + onOriginChange: (v: string) => void; + onDestinationChange: (v: string) => void; + chatInput: string; setChatInput: (v: string) => void; + onChatSubmit: () => void; + chips: string[]; onRemoveChip: (i: number) => void; + vehicle: typeof VEHICLES[number]; onVehicleChange: (v: typeof VEHICLES[number]) => void; + grokStatus: { label?: string }; +}) { + return ( +
+ {/* Brand */} +
+
+ +
+
+
Grok Drive
+ + BETA + +
+
+ + {/* Origin → Destination */} +
+
+
+ onOriginChange(e.target.value)} + placeholder="From" + className="bg-transparent border-none outline-none text-[13px] w-full" + style={{ color: 'var(--gd-text)' }} + /> +
+
+
+ onDestinationChange(e.target.value)} + placeholder="To" + className="bg-transparent border-none outline-none text-[13px] w-full" + style={{ color: 'var(--gd-text)' }} + /> +
+
+ +
+ + {/* Chat composer with chips */} +
+ + {chips.length > 0 && ( +
+ {chips.map((c, i) => ( +
+ {c} + +
+ ))} +
+ )} + setChatInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onChatSubmit()} + placeholder={chips.length ? 'add another...' : 'Refine — "avoid tolls", "lunch in Lyon"'} + className="flex-1 px-3 bg-transparent border-none outline-none text-[13px] min-w-[60px]" + style={{ color: 'var(--gd-text)' }} + /> + +
+ + {/* Vehicle chip */} +
+ + + +
+ + toast.success('GPX exported for your Tesla')}> + + Export + + toast('Shareable link copied')}> + + Share + + {grokStatus.label && ( +
+ {grokStatus.label}
)}
); } +// ─── Main planner ──────────────────────────────────────────────────────────── export default function TeslaTripPlanner() { - const [messages, setMessages] = useState([ - { id: 1, role: 'assistant', content: "Hello! I'm Grok Drive. I'm here to help you plan amazing Tesla road trips across the UK and Europe. Where would you like to go?" }, + 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 [input, setInput] = useState(''); + const [chatInput, setChatInput] = useState(''); + const [chips, setChips] = useState([]); const [thinking, setThinking] = useState(false); const [itinerary, setItinerary] = useState(EMPTY_ITINERARY); const [vehicle, setVehicle] = useState(VEHICLES[0]); - const [grokStatus, setGrokStatus] = useState({ provider: "local", label: "Local Heavy", detail: "", isLocal: true, model: "Heavy" }); + const [grokStatus, setGrokStatus] = useState<{ label?: string }>({ label: 'Local Heavy' }); const [legs, setLegs] = useState([]); + const [activeStopId, setActiveStopId] = useState(null); + const [hoverStopId, setHoverStopId] = useState(null); + const [origin, setOrigin] = useState(''); + const [destination, setDestination] = useState(''); - // Fetch Grok provider status for the badge React.useEffect(() => { - fetch("/api/grok/status").then(r => r.json()).then(setGrokStatus).catch(() => {}); + fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {}); }, []); - // Clean stops for map - const allStops: Stop[] = itinerary.days.flatMap(d => d.stops).filter((s): s is Stop => s != null && typeof s.lat === 'number'); + const allStops: Stop[] = itinerary.days + .flatMap(d => d.stops) + .filter((s): s is Stop => s != null && typeof s.lat === 'number'); - // Use real road routes when available, fallback to straight - const displayPolylines = legs.length > 0 - ? legs.map(l => l.geometry) - : allStops.slice(1).map((stop, i) => { - const prev = allStops[i]; - return [[prev.lat, prev.lng], [stop.lat, stop.lng]] as [number, 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]); - // Lookup: leg by fromId (one leg per "from" stop) const legByFromId = React.useMemo(() => { const map = new Map(); for (const l of legs) map.set(l.fromId, l); return map; }, [legs]); - // When itinerary changes, fetch real road routes + leg metrics React.useEffect(() => { let cancelled = false; const fetchRoutes = async () => { - const stops = itinerary.days - .flatMap(d => d.stops) - .filter((s): s is Stop => s != null && typeof s.lat === 'number'); - - if (stops.length < 2) { - setLegs([]); - return; - } - - console.log('[TeslaTrip] Planning real driving routes between', stops.length, 'stops...'); - + if (allStops.length < 2) { setLegs([]); return; } const fetched: Leg[] = []; - for (let i = 0; i < stops.length - 1; i++) { - const leg = await getRoadLeg(stops[i], stops[i + 1]); + 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); - console.log('[TeslaTrip] Route planning complete. Legs updated on map.'); }; fetchRoutes(); return () => { cancelled = true; }; }, [itinerary]); - // Aggregate live totals from real OSRM legs (more accurate than Grok's estimate) const computedTotals = React.useMemo(() => { if (legs.length === 0) return null; - const km = legs.reduce((sum, l) => sum + (l.distanceKm ?? 0), 0); - const min = legs.reduce((sum, l) => sum + (l.durationMin ?? 0), 0); + 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) => { - if (!text.trim()) return; - - console.log('[TeslaTrip] Sending to Grok:', { message: text.trim(), vehicle: vehicle.name }); - const userMessage = { id: Date.now(), role: 'user' as const, content: text.trim() }; - setMessages(prev => [...prev, userMessage]); - setInput(''); + const trimmed = text.trim(); + if (!trimmed) return; + setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]); + setChatInput(''); + setChips(prev => [...prev, trimmed].slice(-6)); setThinking(true); - try { - const response = await fetch("/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - message: text.trim(), + message: trimmed, vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm }, itinerary, history: messages.map(m => ({ role: m.role, content: m.content })), }), }); - - if (!response.ok) throw new Error("Failed to get response from server"); - + if (!response.ok) throw new Error('Failed to get response from server'); const data = await response.json(); - - console.log('[TeslaTrip] Grok replied:', { replyLength: data.reply?.length, hasItineraryUpdate: !!data.itinerary }); - - const assistantMessage = { - id: Date.now() + 1, - role: 'assistant' as const, - content: data.reply || "Sorry, I could not generate a response.", - }; - setMessages(prev => [...prev, assistantMessage]); - + setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]); if (data.itinerary) { - const cleanItinerary = await normalizeAndSanitizeItinerary(data.itinerary); - console.log('[TeslaTrip] Sanitized itinerary has', cleanItinerary.days.length, 'day(s)'); - setItinerary(cleanItinerary); - - const hasMapStops = cleanItinerary.days.flatMap(d => d.stops).some(s => typeof s.lat === 'number'); - toast.success("Grok updated your route", { - description: hasMapStops - ? `${cleanItinerary.days.length} day(s) • ${cleanItinerary.summary.superchargers} Superchargers` - : `${cleanItinerary.days.length} day(s) (some locations could not be placed on map)`, + const clean = await normalizeAndSanitizeItinerary(data.itinerary); + setItinerary(clean); + toast.success('Grok updated your route', { + description: `${clean.days.length} day(s) · ${clean.summary.superchargers} chargers · ${clean.summary.hotels} overnight`, }); } - } catch (error: any) { - console.error("[TeslaTrip] Grok call failed:", error); - const errorMessage = { - id: Date.now() + 1, - role: 'assistant' as const, - content: error?.message?.includes('Grok') ? error.message : "I'm having trouble reaching Grok right now. Check backend logs (XAI_API_KEY loaded?).", - }; - setMessages(prev => [...prev, errorMessage]); + } catch (err: any) { + console.error('[TeslaTrip] Grok call failed:', err); + setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]); } finally { setThinking(false); } }; const removeStop = (stopId: string) => { - const newItin = structuredClone(itinerary); - newItin.days.forEach(day => { day.stops = day.stops.filter(s => s.id !== stopId); }); - newItin.days = newItin.days.filter(d => d.stops.length > 0); - setItinerary(newItin); - toast.info('Stop removed from itinerary'); + 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 newItin = structuredClone(itinerary); - for (const day of newItin.days) { - const idx = day.stops.findIndex(s => s.id === stopId); + 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 = day.stops[idx]; - // Keep the original as an alternative so the user can swap back + 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, + 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); - day.stops[idx] = { + 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, + 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(newItin); + setItinerary(next); toast.success(`Swapped to ${alt.name}`); }; + const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null; + const dateLabels = ['Today', 'Tomorrow']; + return ( -
- {/* LEFT: CHAT */} -
-
-
-
-
Grok Drive
-
Local Heavy
-
UK & Europe • Headless Grok
-
-
+ +
+ sendMessage(chatInput)} + chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))} + vehicle={vehicle} onVehicleChange={setVehicle} + grokStatus={grokStatus} + /> -
-
YOUR VEHICLE
- -
- -
- - {messages.map((msg, index) => ( - -
- {msg.content} -
-
- ))} -
- - {thinking && ( -
-
-
-
-
-
- GROK IS PLANNING YOUR ROUTE... -
- )} -
- -
- {QUICK_PROMPTS.map((prompt, i) => ( - - ))} -
- -
-
- setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && sendMessage(input)} placeholder="Tell me where you want to drive..." className="flex-1 bg-transparent py-3 text-sm placeholder:text-white/40 outline-none" disabled={thinking} /> - -
-
POWERED BY HEADLESS GROK CLI
-
-
- - {/* RIGHT: MAP + ITINERARY */} - -
-
-
-
- - {computedTotals ? `${Math.round(computedTotals.totalKm)} km` : `${itinerary.summary.totalDistanceKm} km`} -
-
- - {computedTotals ? formatDuration(computedTotals.driveMinutes) : `${itinerary.summary.estDriveHours}h`} drive -
-
{itinerary.summary.estChargeHours}h charging
-
-
- - -
-
- -
-
- - - {allStops.map(stop => ( - + {/* Body: map left, rail right */} +
+ {/* Map */} +
+ + + + + {allStops.map(stop => { + const meta = stopMeta(stop.type); + const isActive = stop.id === activeStopId; + const isHover = stop.id === hoverStopId; + return ( + setActiveStopId(stop.id), + mouseover: () => setHoverStopId(stop.id), + mouseout: () => setHoverStopId(null), + }} + > -
-
{stop.name}
+
+
{stop.name}
{stop.combo && ( -
{stop.combo}
- )} - {stop.description &&
{stop.description}
} -
- {typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 && ⚡ {stop.chargeMinutes} min charge} - {typeof stop.durationMin === 'number' && stop.durationMin > 0 && ⏱ {stop.durationMin} min stop} - {typeof stop.estArrivalBattery === 'number' && 🔋 arrive at {stop.estArrivalBattery}%} - {stop.cuisine && 🍽️ {stop.cuisine}} - {typeof stop.priceLevel === 'number' && {'£'.repeat(Math.min(4, Math.max(1, stop.priceLevel)))}} -
- {stop.amenities && stop.amenities.length > 0 && ( -
- {stop.amenities.slice(0, 8).map(a => ( - {AMENITY_ICONS[a] || '•'} - ))} +
+ {stop.combo}
)} - {stop.notes &&
{stop.notes}
} + {stop.description &&
{stop.description}
}
- ))} - {displayPolylines.map((positions, idx) => ( - - ))} - + ); + })} + {legs.map((leg, i) => ( + + + + + ))} + - {allStops.length === 0 && ( -
-
-
Ready when you are
-
Tell Grok where you want to go and I’ll build the perfect Tesla route.
+ {/* Map legend */} +
+
Charge
+
Sleep
+
See
+
+ + {/* Refinements overlay */} + {chips.length > 0 && ( +
+
+ Refinements applied · {chips.length} +
+
+ {chips.map((c, i) => ( + + {c} + + ))} +
+
+ )} + + {/* Empty state overlay */} + {allStops.length === 0 && !thinking && ( +
+
+
Where to?
+
+ Use the chat above to describe your trip. Try one of the quick prompts in the stops rail.
- )} -
-
- -
- {itinerary.days.length > 0 ? ( -
- {itinerary.days.map((day, di) => { - const validStops = (day.stops || []).filter((s): s is Stop => s != null).sort((a,b) => a.order - b.order); - // Compute day-level totals from real legs (within this day's stops only) - const dayLegs: Leg[] = []; - for (let i = 0; i < validStops.length - 1; i++) { - const leg = legByFromId.get(validStops[i].id); - if (leg && leg.toId === validStops[i + 1].id) dayLegs.push(leg); - } - const dayKm = dayLegs.reduce((s, l) => s + (l.distanceKm ?? 0), 0); - const dayMin = dayLegs.reduce((s, l) => s + (l.durationMin ?? 0), 0); - return ( -
-
-
DAY {day.day}
- {day.title &&
{day.title}
} -
- {dayLegs.length > 0 && ( -
- {Math.round(dayKm)} km · {formatDuration(dayMin)} driving -
- )} - {validStops.length > 0 ? validStops.map(stop => { - const allStopsIndex = allStops.findIndex(s => s.id === stop.id); - const prevStop = allStopsIndex > 0 ? allStops[allStopsIndex - 1] : null; - const legAbove = prevStop ? legByFromId.get(prevStop.id) : undefined; - return ( -
-
- {legAbove && } -
- removeStop(stop.id)} onSwap={(alt) => swapStop(stop.id, alt)} /> -
- ); - }) :
No valid stops for this day
} -
- ); - })} - {itinerary.summary.highlights && itinerary.summary.highlights.length > 0 && ( -
-
HIGHLIGHTS
-
    - {itinerary.summary.highlights.map((h, i) => ( -
  • {h}
  • - ))} -
-
- )} -
- ) : ( -
-
No trip planned yet
-
Describe your journey in the chat and I’ll create the perfect route with Superchargers, hotels, food and combo stops.
)}
+ + {/* Stops rail */} +
- -
+
+ ); } diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index 2b916d9..1222c37 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -1,51 +1,81 @@ +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; :root { - --tesla-red: #E82127; - --bg: #0a0a0a; - --bg-elevated: #111111; - --bg-card: #1a1f2b; - --border: rgba(255, 255, 255, 0.08); + /* Direction B palette */ + --gd-bg: #0a0a0c; + --gd-bg-2: #111114; + --gd-panel: #15151a; + --gd-panel-2: #1c1c22; + --gd-border: rgba(255, 255, 255, 0.08); + --gd-border-2: rgba(255, 255, 255, 0.14); + --gd-text: #f5f5f7; + --gd-text-2: #a8a8b0; + --gd-text-3: #6c6c75; + --gd-red: #e31937; + --gd-red-soft: rgba(227, 25, 55, 0.14); + --gd-red-line: rgba(227, 25, 55, 0.32); + --gd-green: #4ade80; + --gd-amber: #fbbf24; + --gd-blue: #60a5fa; + --gd-purple: #c084fc; + + /* Legacy aliases (still referenced in a few places) */ + --tesla-red: var(--gd-red); + --bg: var(--gd-bg); + --bg-elevated: var(--gd-bg-2); + --bg-card: var(--gd-panel); + --border: var(--gd-border); } -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - background-color: var(--bg); - color: white; +html, body, #root { + margin: 0; + padding: 0; + height: 100%; + background: var(--gd-bg); + color: var(--gd-text); + font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + letter-spacing: -0.01em; + -webkit-font-smoothing: antialiased; } -/* Tesla-inspired scrollbar */ +.mono { font-family: 'Geist Mono', ui-monospace, monospace; } +.num { font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; } + ::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { - background: #1a1a1a; -} +::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: #3a3a3a; + background: rgba(255, 255, 255, 0.12); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: #E82127; + background: rgba(255, 255, 255, 0.22); } -/* Map container */ .leaflet-container { background: #0a0f1a !important; + font-family: inherit; } -/* Chat bubbles */ -.chat-bubble-user { - background: #E82127; - color: white; - border-bottom-right-radius: 4px; +/* Override Leaflet popup styling to match Direction B */ +.leaflet-popup-content-wrapper { + background: rgba(20, 20, 24, 0.95); + color: var(--gd-text); + border: 1px solid var(--gd-border-2); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); } - -.chat-bubble-assistant { - background: #1f242e; - border: 1px solid rgba(255,255,255,0.08); - border-bottom-left-radius: 4px; +.leaflet-popup-tip { + background: rgba(20, 20, 24, 0.95); +} +.leaflet-popup-content { + margin: 10px 12px; + font-size: 12px; + line-height: 1.5; }