@@ -0,0 +1,424 @@
import React , { useState } from 'react' ;
import { motion , AnimatePresence } from 'framer-motion' ;
import { Send , MapPin , BatteryCharging , Clock , Share2 , Download , Car , Zap , AlertTriangle } from 'lucide-react' ;
import { toast } from 'sonner' ;
import { MapContainer , TileLayer , Marker , Polyline , Popup } from 'react-leaflet' ;
import L from 'leaflet' ;
// Fix Leaflet default icons
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' ,
} ) ;
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 } ,
] ;
interface Stop {
id : string ;
name : string ;
type : 'supercharger' | 'hotel' | 'attraction' | 'restaurant' | 'custom' ;
lat : number ;
lng : number ;
day : number ;
order : number ;
estArrivalBattery? : number ;
chargeMinutes? : number ;
notes? : string ;
}
interface Itinerary {
days : { day : number ; stops : Stop [ ] } [ ] ;
summary : { totalDistanceKm : number ; estDriveHours : number ; estChargeHours : number ; superchargers : number ; hotels : number } ;
}
const EMPTY_ITINERARY : Itinerary = {
days : [ ] ,
summary : { totalDistanceKm : 0 , estDriveHours : 0 , estChargeHours : 0 , superchargers : 0 , hotels : 0 } ,
} ;
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" ,
] ;
// Simple in-memory geocache
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 ) ) ; // be nice to Nominatim
return result ;
}
} catch ( e ) {
console . warn ( '[TeslaTrip] Geocoding failed for' , query ) ;
}
return null ;
}
// Very forgiving sanitization + geocoding
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 ;
let name = s . name || s . location || s ;
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 ; }
}
if ( lat === null || lng === null ) {
validStops . push ( {
id : s.id || ` text- ${ Date . now ( ) } - ${ Math . random ( ) } ` ,
name , type : s . type || 'custom' , lat : null , lng : null ,
day : day.day || 1 , order : s.order || validStops . length + 1 ,
estArrivalBattery : s.estArrivalBattery , chargeMinutes : s.chargeMinutes , notes : s.notes ,
} ) ;
continue ;
}
validStops . push ( {
id : s.id || ` stop- ${ Date . now ( ) } - ${ Math . random ( ) } ` ,
name , type : [ 'supercharger' , 'hotel' , 'attraction' , 'restaurant' , 'custom' ] . includes ( s . type ) ? s . type : 'custom' ,
lat , lng , day : day.day || 1 , order : s.order || validStops . length + 1 ,
estArrivalBattery : s.estArrivalBattery , chargeMinutes : s.chargeMinutes , notes : s.notes ,
} ) ;
}
if ( validStops . length > 0 ) {
normalizedDays . push ( { day : day.day || normalizedDays . length + 1 , 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' ) . length ,
hotels : allStops.filter ( s = > s . type === 'hotel' ) . length ,
} ,
} ;
}
// 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 (
< div className = "h-full flex items-center justify-center bg-red-950/30 text-red-400 p-6 rounded-2xl" >
< div className = "text-center" >
< AlertTriangle className = "w-8 h-8 mx-auto mb-3" / >
< div > Something went wrong rendering the map / itinerary . < / div >
< div className = "text-xs mt-1 text-red-400/70" > Check console for details . < / div >
< / div >
< / div >
) ;
}
return this . props . children ;
}
}
// Fetch actual road route using OSRM (free, no key)
async function getRoadRoute ( from : Stop , to : Stop ) : Promise < [ number , number ] [ ] > {
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 ( ) ;
if ( data . routes && data . routes [ 0 ] ) {
return data . routes [ 0 ] . geometry . coordinates . map ( ( c : number [ ] ) = > [ c [ 1 ] , c [ 0 ] ] ) ; // OSRM returns [lng, lat]
}
} catch ( e ) {
console . warn ( '[TeslaTrip] OSRM routing failed, falling back to straight line' ) ;
}
return [ [ from . lat , from . lng ] , [ to . lat , to . lng ] ] ;
}
export default function TeslaTripPlanner() {
const [ messages , setMessages ] = useState < any [ ] > ( [
{ 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 [ input , setInput ] = useState ( '' ) ;
const [ thinking , setThinking ] = useState ( false ) ;
const [ itinerary , setItinerary ] = useState < Itinerary > ( EMPTY_ITINERARY ) ;
const [ vehicle , setVehicle ] = useState ( VEHICLES [ 0 ] ) ;
const [ roadRoutes , setRoadRoutes ] = useState < [ number , number ] [ ] [ ] > ( [ ] ) ;
// Clean stops for map
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 = roadRoutes . length > 0 ? roadRoutes : allStops.slice ( 1 ) . map ( ( stop , i ) = > {
const prev = allStops [ i ] ;
return [ [ prev . lat , prev . lng ] , [ stop . lat , stop . lng ] ] as [ number , number ] [ ] ;
} ) ;
// When itinerary changes, fetch real road routes
React . useEffect ( ( ) = > {
const fetchRoutes = async ( ) = > {
if ( allStops . length < 2 ) {
setRoadRoutes ( [ ] ) ;
return ;
}
const routes : [ number , number ] [ ] [ ] = [ ] ;
for ( let i = 0 ; i < allStops . length - 1 ; i ++ ) {
const route = await getRoadRoute ( allStops [ i ] , allStops [ i + 1 ] ) ;
routes . push ( route ) ;
}
setRoadRoutes ( routes ) ;
} ;
fetchRoutes ( ) ;
} , [ itinerary ] ) ;
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 ( '' ) ;
setThinking ( true ) ;
try {
const response = await fetch ( "/api/chat" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( {
message : text.trim ( ) ,
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" ) ;
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 ] ) ;
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) ` ,
} ) ;
}
} 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 ] ) ;
} 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' ) ;
} ;
return (
< div className = "flex h-screen bg-[#0a0a0a] text-white overflow-hidden" >
{ /* LEFT: CHAT */ }
< div className = "w-[380px] flex flex-col border-r border-white/10 bg-[#111111]" >
< div className = "p-5 border-b border-white/10 flex items-center gap-3 bg-black/60" >
< div className = "w-9 h-9 rounded-full bg-[#E82127] flex items-center justify-center" > < Zap className = "w-5 h-5" / > < / div >
< div >
< div className = "font-semibold text-lg tracking-tight" > Grok Drive < / div >
< div className = "text-xs text-white/50" > UK & amp ; Europe • Headless Grok < / div >
< / div >
< / div >
< div className = "p-4 border-b border-white/10 bg-black/30" >
< div className = "text-[10px] uppercase tracking-[1.5px] text-white/40 mb-1.5 px-1" > YOUR VEHICLE < / div >
< select value = { vehicle . name } onChange = { e = > setVehicle ( VEHICLES . find ( v = > v . name === e . target . value ) ! ) } className = "w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-2.5 text-sm focus:border-[#E82127]" >
{ VEHICLES . map ( v = > < option key = { v . name } value = { v . name } > { v . name } — { v . rangeKm } km < / option > ) }
< / select >
< / div >
< div className = "flex-1 overflow-y-auto p-4 space-y-6 text-sm" >
< AnimatePresence >
{ messages . map ( ( msg , index ) = > (
< motion.div key = { index } initial = { { opacity : 0 , y : 10 } } animate = { { opacity : 1 , y : 0 } } className = { ` flex ${ msg . role === 'user' ? 'justify-end' : '' } ` } >
< div className = { ` max-w-[85%] rounded-2xl px-4 py-3 leading-snug ${ msg . role === 'user' ? 'bg-[#E82127] text-white rounded-tr-sm' : 'bg-[#1f242e] border border-white/10 rounded-tl-sm' } ` } >
{ msg . content }
< / div >
< / motion.div >
) ) }
< / AnimatePresence >
{ thinking && (
< div className = "flex items-center gap-2 pl-1 text-[#E82127]" >
< div className = "flex gap-1" >
< div className = "w-1 h-1 bg-current rounded-full animate-bounce" / >
< div className = "w-1 h-1 bg-current rounded-full animate-bounce delay-150" / >
< div className = "w-1 h-1 bg-current rounded-full animate-bounce delay-300" / >
< / div >
< span className = "text-xs tracking-widest" > GROK IS PLANNING YOUR ROUTE . . . < / span >
< / div >
) }
< / div >
< div className = "p-3 border-t border-white/10 flex flex-wrap gap-1.5 bg-black/40" >
{ QUICK_PROMPTS . map ( ( prompt , i ) = > (
< button key = { i } onClick = { ( ) = > sendMessage ( prompt ) } disabled = { thinking } className = "text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition disabled:opacity-50" >
{ prompt . length > 42 ? prompt . slice ( 0 , 39 ) + '...' : prompt }
< / button >
) ) }
< / div >
< div className = "p-3 border-t border-white/10 bg-black/40" >
< div className = "flex items-center gap-2 bg-[#1a1a1a] border border-white/10 rounded-2xl px-3" >
< input value = { input } onChange = { e = > 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 } / >
< button onClick = { ( ) = > sendMessage ( input ) } disabled = { ! input . trim ( ) || thinking } className = "p-2 rounded-xl bg-[#E82127] disabled:opacity-40 hover:bg-[#c01a20] transition" >
< Send className = "w-4 h-4" / >
< / button >
< / div >
< div className = "text-center text-[9px] text-white/30 mt-2 tracking-[1.5px]" > POWERED BY HEADLESS GROK CLI < / div >
< / div >
< / div >
{ /* RIGHT: MAP + ITINERARY */ }
< ErrorBoundary >
< div className = "flex-1 flex flex-col min-w-0" >
< div className = "h-14 border-b border-white/10 bg-black/40 px-6 flex items-center justify-between text-sm" >
< div className = "flex items-center gap-8" >
< div > < MapPin className = "inline w-4 h-4 mr-1 text-[#E82127]" / > { itinerary . summary . totalDistanceKm } km < / div >
< div > < Clock className = "inline w-4 h-4 mr-1 text-[#E82127]" / > { itinerary . summary . estDriveHours } h drive < / div >
< div > < BatteryCharging className = "inline w-4 h-4 mr-1 text-[#E82127]" / > { itinerary . summary . estChargeHours } h charging < / div >
< / div >
< div className = "flex gap-2" >
< button onClick = { ( ) = > toast . success ( 'GPX exported for your Tesla' ) } className = "flex items-center gap-1.5 px-3 py-1.5 text-xs border border-white/20 rounded-lg hover:bg-white/5" >
< Download className = "w-3.5 h-3.5" / > GPX
< / button >
< button onClick = { ( ) = > toast ( 'Shareable link copied' ) } className = "flex items-center gap-1.5 px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 rounded-lg" >
< Share2 className = "w-3.5 h-3.5" / > Share
< / button >
< / div >
< / div >
< div className = "flex-1 bg-[#05070d] p-3" >
< div className = "w-full h-full rounded-2xl overflow-hidden border border-white/10 relative" >
< MapContainer center = { [ 54.5 , - 2.5 ] } zoom = { 5.5 } style = { { height : '100%' , width : '100%' , background : '#0a0f1a' } } >
< TileLayer attribution = '© OpenStreetMap contributors' url = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" / >
{ allStops . map ( stop = > (
< Marker key = { stop . id } position = { [ stop . lat , stop . lng ] } >
< Popup > < strong > { stop . name } < / strong > < br / > { stop . type === 'supercharger' && ` ⚡ ${ stop . chargeMinutes } min charge ` } { stop . type === 'hotel' && '🏨 Destination charging available' } < / Popup >
< / Marker >
) ) }
{ displayPolylines . map ( ( positions , idx ) = > (
< Polyline key = { idx } positions = { positions } color = "#E82127" weight = { 4 } opacity = { 0.8 } / >
) ) }
< / MapContainer >
{ allStops . length === 0 && (
< div className = "absolute inset-0 flex items-center justify-center bg-black/40 pointer-events-none" >
< div className = "text-center px-6" >
< div className = "text-2xl mb-2 text-white/80" > Ready when you are < / div >
< div className = "text-white/50" > Tell Grok where you want to go and I ’ ll build the perfect Tesla route . < / div >
< / div >
< / div >
) }
< / div >
< / div >
< div className = "h-[215px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto" >
{ itinerary . days . length > 0 ? (
< div className = "flex gap-6 min-w-max" >
{ itinerary . days . map ( ( day , di ) = > {
const validStops = ( day . stops || [ ] ) . filter ( ( s ) : s is Stop = > s != null ) ;
return (
< div key = { di } className = "w-[310px]" >
< div className = "uppercase text-xs tracking-[2px] text-[#E82127] mb-2" > DAY { day . day } < / div >
{ validStops . length > 0 ? validStops . sort ( ( a , b ) = > a . order - b . order ) . map ( stop = > (
< div key = { stop . id } className = "group flex justify-between items-center bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 mb-1.5 text-sm" >
< div className = "flex items-center gap-3" >
< div className = { ` w-2.5 h-2.5 rounded-full flex-shrink-0 ${ stop . type === 'supercharger' ? 'bg-[#E82127]' : stop . type === 'hotel' ? 'bg-blue-500' : 'bg-white/60' } ` } / >
< div >
< div className = "font-medium leading-tight" > { stop . name } < / div >
{ typeof stop . lat === 'number' ? < div className = "text-xs text-emerald-400" > Placed on map < / div > : < div className = "text-xs text-amber-400" > Location not yet on map < / div > }
< / div >
< / div >
< button onClick = { ( ) = > removeStop ( stop . id ) } className = "opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition" >
< svg xmlns = "http://www.w3.org/2000/svg" className = "w-4 h-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" > < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" / > < / svg >
< / button >
< / div >
) ) : < div className = "text-xs text-white/40 italic" > No valid stops for this day < / div > }
< / div >
) ;
} ) }
< / div >
) : (
< div className = "h-full flex flex-col items-center justify-center text-center" >
< div className = "text-white/60 mb-2" > No trip planned yet < / div >
< div className = "text-sm text-white/40 max-w-xs" > Describe your journey in the chat and I ’ ll create the perfect route with Superchargers and stops . < / div >
< / div >
) }
< / div >
< / div >
< / ErrorBoundary >
< / div >
) ;
}