@@ -20,27 +20,65 @@ const VEHICLES = [
{ name : 'Model Y RWD (EU)' , rangeKm : 455 , efficiency : 158 } ,
] ;
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' ;
interface Stop {
id : string ;
name : string ;
type : 'supercharger' | 'hotel' | 'attraction' | 'restaurant' | 'custom' ;
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 ;
}
interface Itinerary {
days : { day : number ; stops : Stop [ ] } [ ] ;
summary : { totalDistanceKm : number ; estDriveHours : number ; estChargeHours : number ; superchargers : number ; hotels : number } ;
days : { day : number ; title? : string ; stops : Stop [ ] } [ ] ;
summary : {
totalDistanceKm : number ;
estDriveHours : number ;
estChargeHours : number ;
superchargers : number ;
hotels : number ;
highlights? : string [ ] ;
} ;
}
const EMPTY_ITINERARY : Itinerary = {
days : [ ] ,
summary : { totalDistanceKm : 0 , estDriveHours : 0 , estChargeHours : 0 , superchargers : 0 , hotels : 0 } ,
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 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 : '🏊' ,
} ;
const QUICK_PROMPTS = [
@@ -102,26 +140,44 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if ( geo ) { lat = geo . lat ; lng = geo . lng ; }
}
const sharedFields = {
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 ,
} ;
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 : s . type || 'custom' , lat : null , lng : null ,
name , type : resolvedType , lat : null , lng : null ,
day : day.day || 1 , order : s.order || validStops . length + 1 ,
estArrivalBattery : s.estArrivalBattery , chargeMinutes : s.chargeMinutes , notes : s.notes ,
. . . sharedFields ,
} ) ;
continue ;
}
validStops . push ( {
id : s.id || ` stop- ${ Date . now ( ) } - ${ Math . random ( ) } ` ,
name , type : [ 'supercharger' , 'hotel' , 'attraction' , 'restaurant' , 'custom' ] . includes ( s . type ) ? s . type : 'custom' ,
name , type : resolvedType ,
lat , lng , day : day.day || 1 , order : s.order || validStops . length + 1 ,
estArrivalBattery : s.estArrivalBattery , chargeMinutes : s.chargeMinutes , notes : s.notes ,
. . . sharedFields ,
} ) ;
}
if ( validStops . length > 0 ) {
normalizedDays . push ( { day : day.day || normalizedDays . length + 1 , stops : validStops.sort ( ( a , b ) = > a . order - b . order ) } ) ;
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 ) ,
} ) ;
}
}
@@ -134,8 +190,11 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
totalDistanceKm : raw.summary?.totalDistanceKm ? ? 0 ,
estDriveHours : raw.summary?.estDriveHours ? ? 0 ,
estChargeHours : raw.summary?.estChargeHours ? ? 0 ,
superchargers : allStops.filter ( s = > s . type === 'supercharger' ) . length ,
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' )
: [ ] ,
} ,
} ;
}
@@ -176,6 +235,61 @@ async function getRoadRoute(from: Stop, to: Stop): Promise<[number, number][]> {
return [ [ from . lat , from . lng ] , [ to . lat , to . lng ] ] ;
}
const STOP_DOT : Record < StopType , string > = {
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 StopCard ( { stop , onRemove } : { stop : Stop ; onRemove : ( ) = > void } ) {
const amenities = ( stop . amenities || [ ] ) . slice ( 0 , 6 ) ;
return (
< div className = "group bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 mb-1.5 text-sm" >
< div className = "flex justify-between items-start gap-2" >
< div className = "flex items-start gap-3 min-w-0 flex-1" >
< div className = { ` w-2.5 h-2.5 mt-1.5 rounded-full flex-shrink-0 ${ STOP_DOT [ stop . type ] || 'bg-white/60' } ` } / >
< div className = "min-w-0 flex-1" >
< div className = "font-medium leading-tight truncate" > { stop . name } < / div >
{ stop . combo && (
< div className = "inline-block mt-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-[#E82127]/15 text-[#E82127] uppercase tracking-wider" >
{ stop . combo }
< / div >
) }
{ stop . description && (
< div className = "text-xs text-white/60 mt-1 leading-snug line-clamp-2" > { stop . description } < / div >
) }
< div className = "flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-[11px] text-white/50" >
{ typeof stop . chargeMinutes === 'number' && stop . chargeMinutes > 0 && < span > ⚡ { stop . chargeMinutes } m charge < / span > }
{ typeof stop . durationMin === 'number' && stop . durationMin > 0 && < span > ⏱ { stop . durationMin } m stop < / span > }
{ typeof stop . estArrivalBattery === 'number' && < span > 🔋 { stop . estArrivalBattery } % < / span > }
{ stop . cuisine && < span className = "truncate max-w-[120px]" > 🍽 ️ { stop . cuisine } < / span > }
{ typeof stop . priceLevel === 'number' && < span > { '£' . repeat ( Math . min ( 4 , Math . max ( 1 , stop . priceLevel ) ) ) } < / span > }
< / div >
{ amenities . length > 0 && (
< div className = "flex flex-wrap gap-1 mt-1.5" >
{ amenities . map ( a = > (
< span key = { a } title = { a } className = "text-sm leading-none" > { AMENITY_ICONS [ a ] || '•' } < / span >
) ) }
< / div >
) }
{ typeof stop . lat !== 'number' && (
< div className = "text-[11px] text-amber-400 mt-1" > Location not yet on map < / div >
) }
< / div >
< / div >
< button onClick = { onRemove } className = "opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition flex-shrink-0" >
< 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 >
) ;
}
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?" } ,
@@ -184,8 +298,14 @@ export default function TeslaTripPlanner() {
const [ thinking , setThinking ] = useState ( false ) ;
const [ itinerary , setItinerary ] = useState < Itinerary > ( EMPTY_ITINERARY ) ;
const [ vehicle , setVehicle ] = useState ( VEHICLES [ 0 ] ) ;
const [ grokStatus , setGrokStatus ] = useState ( { provider : "local" , label : "Local Heavy" , detail : "" , isLocal : true , model : "Heavy" } ) ;
const [ roadRoutes , setRoadRoutes ] = useState < [ number , number ] [ ] [ ] > ( [ ] ) ;
// Fetch Grok provider status for the badge
React . useEffect ( ( ) = > {
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' ) ;
@@ -195,19 +315,27 @@ export default function TeslaTripPlanner() {
return [ [ prev . lat , prev . lng ] , [ stop . lat , stop . lng ] ] as [ number , number ] [ ] ;
} ) ;
// When itinerary changes, fetch real road routes
// When itinerary changes, fetch real road routes using OSRM
React . useEffect ( ( ) = > {
const fetchRoutes = async ( ) = > {
if ( allStops . length < 2 ) {
const stops = itinerary . days
. flatMap ( d = > d . stops )
. filter ( ( s ) : s is Stop = > s != null && typeof s . lat === 'number' ) ;
if ( stops . length < 2 ) {
setRoadRoutes ( [ ] ) ;
return ;
}
console . log ( '[TeslaTrip] Planning real driving routes between' , stops . length , 'stops...' ) ;
const routes : [ number , number ] [ ] [ ] = [ ] ;
for ( let i = 0 ; i < allStops . length - 1 ; i ++ ) {
const route = await getRoadRoute ( allStops [ i ] , allStops [ i + 1 ] ) ;
for ( let i = 0 ; i < stops . length - 1 ; i ++ ) {
const route = await getRoadRoute ( stops [ i ] , stops [ i + 1 ] ) ;
routes . push ( route ) ;
}
setRoadRoutes ( routes ) ;
console . log ( '[TeslaTrip] Route planning complete. Polylines updated on map.' ) ;
} ;
fetchRoutes ( ) ;
} , [ itinerary ] ) ;
@@ -287,6 +415,7 @@ export default function TeslaTripPlanner() {
< 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 = "ml-3 text-[10px] font-medium px-2.5 py-0.5 rounded-full border bg-emerald-500/10 text-emerald-400 border-emerald-500/30" > Local Heavy < / div >
< div className = "text-xs text-white/50" > UK & amp ; Europe • Headless Grok < / div >
< / div >
< / div >
@@ -365,7 +494,30 @@ export default function TeslaTripPlanner() {
< 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 >
< Popup >
< div className = "text-[13px] leading-snug" >
< div className = "font-semibold text-sm mb-0.5" > { stop . name } < / div >
{ stop . combo && (
< div className = "text-[11px] font-medium text-[#E82127] uppercase tracking-wider mb-1" > { stop . combo } < / div >
) }
{ stop . description && < div className = "mb-1.5" > { stop . description } < / div > }
< div className = "flex flex-wrap gap-1.5 text-xs text-slate-700" >
{ typeof stop . chargeMinutes === 'number' && stop . chargeMinutes > 0 && < span > ⚡ { stop . chargeMinutes } min charge < / span > }
{ typeof stop . durationMin === 'number' && stop . durationMin > 0 && < span > ⏱ { stop . durationMin } min stop < / span > }
{ typeof stop . estArrivalBattery === 'number' && < span > 🔋 arrive at { stop . estArrivalBattery } % < / span > }
{ stop . cuisine && < span > 🍽 ️ { stop . cuisine } < / span > }
{ typeof stop . priceLevel === 'number' && < span > { '£' . repeat ( Math . min ( 4 , Math . max ( 1 , stop . priceLevel ) ) ) } < / span > }
< / div >
{ stop . amenities && stop . amenities . length > 0 && (
< div className = "flex flex-wrap gap-1 mt-1.5" >
{ stop . amenities . slice ( 0 , 8 ) . map ( a = > (
< span key = { a } title = { a } className = "text-base leading-none" > { AMENITY_ICONS [ a ] || '•' } < / span >
) ) }
< / div >
) }
{ stop . notes && < div className = "mt-1.5 text-[11px] text-slate-500 italic" > { stop . notes } < / div > }
< / div >
< / Popup >
< / Marker >
) ) }
{ displayPolylines . map ( ( positions , idx ) = > (
@@ -384,36 +536,38 @@ export default function TeslaTripPlanner() {
< / div >
< / div >
< div className = "h-[215 px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto" >
< div className = "h-[280 px] 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-[31 0px]" >
< div className = "uppercase text-xs tracking-[2px] text-[#E82127] mb-2" > DAY { day . day } < / div >
< div key = { di } className = "w-[34 0px]" >
< div className = "flex items-baseline gap-2 mb-2" >
< div className = "uppercase text-xs tracking-[2px] text-[#E82127]" > DAY { day . day } < / div >
{ day . title && < div className = "text-xs text-white/60 truncate" > { day . title } < / div > }
< / 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 >
< StopCard key = { stop . id } stop = { stop } onRemove = { ( ) = > removeStop ( stop . id ) } / >
) ) : < div className = "text-xs text-white/40 italic" > No valid stops for this day < / div > }
< / div >
) ;
} ) }
{ itinerary . summary . highlights && itinerary . summary . highlights . length > 0 && (
< div className = "w-[260px] border-l border-white/5 pl-5" >
< div className = "uppercase text-xs tracking-[2px] text-[#E82127] mb-2" > HIGHLIGHTS < / div >
< ul className = "space-y-1.5 text-xs text-white/80" >
{ itinerary . summary . highlights . map ( ( h , i ) = > (
< li key = { i } className = "flex gap-2" > < span className = "text-[#E82127]" > ★ < / span > { h } < / li >
) ) }
< / ul >
< / 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 className = "text-sm text-white/40 max-w-xs" > Describe your journey in the chat and I ’ ll create the perfect route with Superchargers , hotels , food and combo stops . < / div >
< / div >
) }
< / div >