chore: initial checkpoint - Tesla Roadtrip planner
- Proactive Grok integration (xAI API + local CLI fallback) - Real road routing via OSRM (no more bird's-eye lines) - Heavy structured logging for fast iteration - Strong sanitization + geocoding + ErrorBoundary (no black screens) - Playwright E2E tests (API diagnostic + full UI flow) - scripts/dev.sh for one-command startup - Clean .env.example + documentation This is a stable checkpoint before further prompt/UI refinement.
This commit is contained in:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user