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,19 @@
|
||||
# Tesla Roadtrip - Environment Configuration
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# === REQUIRED: xAI API Key for real Grok responses ===
|
||||
# Get one at https://console.x.ai
|
||||
XAI_API_KEY=xai-YourKeyHere
|
||||
|
||||
# === Optional: App configuration ===
|
||||
APP_URL=http://localhost:5173
|
||||
API_URL=http://localhost:3000
|
||||
|
||||
# === Optional: Auth service (from auth.tony.codes) ===
|
||||
# Only needed if testing authenticated features locally
|
||||
# AUTH_SECRET=your-auth-secret-here
|
||||
# AUTH_URL=https://auth.tony.codes
|
||||
|
||||
# === Optional: Force local grok CLI (advanced) ===
|
||||
# Set to false to always use xAI API instead of local CLI
|
||||
# GROK_ENABLED=true
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
client/dist/
|
||||
build/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Prisma generated client (generated at runtime)
|
||||
prisma/generated/
|
||||
|
||||
# Atlas local files (if any)
|
||||
atlas/migrations/*_dev/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
@@ -0,0 +1,108 @@
|
||||
# Tesla Roadtrip — Grok-Powered Planner (UK & Europe First)
|
||||
|
||||
Beautiful split-pane Tesla trip planner.
|
||||
|
||||
**Left side**: Natural language chat with Grok Drive (powered by the real xAI Grok API)
|
||||
**Right side**: Interactive Leaflet map + live itinerary that updates as you talk
|
||||
|
||||
## First Release Focus
|
||||
|
||||
- United Kingdom
|
||||
- Europe (France, Germany, Benelux, Switzerland, Spain)
|
||||
|
||||
## Key Features
|
||||
|
||||
- Real Grok intelligence via the xAI API
|
||||
- Excellent Supercharger coverage awareness across the UK & Europe
|
||||
- Metric units (km, Wh/km)
|
||||
- Proactive route planning — just say where you want to go
|
||||
- Safe, restricted tool use
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Vite + React + Tailwind + Leaflet (client)
|
||||
- Express + Prisma + pino (server)
|
||||
- Real xAI Grok API (with local `grok` CLI as optional path)
|
||||
- Heavy structured logging for fast debugging
|
||||
|
||||
## Local Development
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cd client && npm install && cd ..
|
||||
```
|
||||
|
||||
### 2. Set up environment variables
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Then edit `.env` and add your xAI API key:
|
||||
|
||||
```env
|
||||
XAI_API_KEY=xai-YourKeyHere
|
||||
```
|
||||
|
||||
You can get a key from: https://console.x.ai
|
||||
|
||||
### 3. Start the development environment
|
||||
|
||||
You have two options:
|
||||
|
||||
**Option A (Recommended)** – Better developer experience:
|
||||
|
||||
```bash
|
||||
./scripts/dev.sh
|
||||
```
|
||||
|
||||
**Option B** – Using npm:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Both commands will start:
|
||||
- **Backend**: http://localhost:3000 (with detailed logging)
|
||||
- **Frontend**: http://localhost:5173
|
||||
|
||||
### 4. Open the app
|
||||
|
||||
Visit: **http://localhost:5173**
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
| Command | Description |
|
||||
|------------------------|------------------------------------------|
|
||||
| `npm run dev` | Start both frontend and backend |
|
||||
| `./scripts/dev.sh` | Recommended way to start (better output) |
|
||||
| `npm run dev:server` | Start only the backend |
|
||||
| `npm run dev:client` | Start only the frontend |
|
||||
|
||||
---
|
||||
|
||||
## Security & Philosophy
|
||||
|
||||
This project follows a strict security policy:
|
||||
- No packages with known active critical or high vulnerabilities
|
||||
- `react-markdown` / remark ecosystem avoided (LLM output sanitization via DOMPurify)
|
||||
- All AI tool use is heavily restricted
|
||||
- Maximum logging so we can debug the "chat → route → stops" flow easily
|
||||
|
||||
## Deployment Target
|
||||
|
||||
Will be deployed via Dokku on Hetzner with Gitea CI.
|
||||
|
||||
First launch target: United Kingdom & Europe.
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Make sure your `XAI_API_KEY` is set (otherwise you'll get very basic responses)
|
||||
- Check the backend terminal — it has extremely detailed logs
|
||||
- The app is designed so you can iterate quickly by watching the logs
|
||||
@@ -0,0 +1,13 @@
|
||||
data "external_schema" "prisma" {
|
||||
program = ["npx", "prisma", "migrate", "diff", "--from-empty", "--to-schema", "prisma/schema.prisma", "--script"]
|
||||
}
|
||||
|
||||
env "dev" {
|
||||
url = getenv("DATABASE_URL")
|
||||
dev = getenv("ATLAS_DEV_URL")
|
||||
schema { src = data.external_schema.prisma.url }
|
||||
migration {
|
||||
dir = "file://atlas/migrations"
|
||||
exclude = ["_prisma_migrations"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tesla-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tesla Trip Planner • Grok</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "tesla-roadtrip-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tonycodes/auth-react": "^1.4.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.303.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"sonner": "^1.4.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"dompurify": "^3.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.14",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.8",
|
||||
"@types/dompurify": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { AuthCallback } from '@tonycodes/auth-react';
|
||||
import TeslaTripPlanner from './pages/TeslaTripPlanner';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/*" element={<TeslaTripPlanner />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Toaster } from 'sonner';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@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);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Tesla-inspired scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #E82127;
|
||||
}
|
||||
|
||||
/* Map container */
|
||||
.leaflet-container {
|
||||
background: #0a0f1a !important;
|
||||
}
|
||||
|
||||
/* Chat bubbles */
|
||||
.chat-bubble-user {
|
||||
background: #E82127;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-assistant {
|
||||
background: #1f242e;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
tesla: {
|
||||
red: '#E82127',
|
||||
black: '#0a0a0a',
|
||||
dark: '#111111',
|
||||
gray: '#1a1f2b',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dev override for tesla-roadtrip.test
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
target: development
|
||||
command: ["npm", "run", "dev"]
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
- ./prisma:/app/prisma
|
||||
labels: !reset {}
|
||||
frontend:
|
||||
build:
|
||||
target: development
|
||||
command: ["npm", "run", "dev"]
|
||||
volumes:
|
||||
- ./client:/app/client
|
||||
labels:
|
||||
- "caddy=tesla-roadtrip.test"
|
||||
- "caddy.reverse_proxy=/api/* app:3000"
|
||||
- "caddy.reverse_proxy=/* {upstream http://frontend:5173}"
|
||||
Generated
+6955
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "tesla-roadtrip",
|
||||
"version": "0.1.0",
|
||||
"description": "AI-powered conversational road trip planner for Tesla owners. Chat with Grok on the left, live interactive map + itinerary on the right.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"cyan,green\" \"npm:dev:server\" \"npm:dev:client\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:client": "npm --prefix client run dev",
|
||||
"build": "npm --prefix client run build && tsc -p tsconfig.json",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "atlas migrate apply --env dev",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^7.4.0",
|
||||
"@tonycodes/auth-express": "^1.4.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^5.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/cookie-parser": "^1.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.0.0",
|
||||
"husky": "^9.0.0",
|
||||
"lint-staged": "^15.0.0",
|
||||
"prettier": "^3.2.0",
|
||||
"prisma": "^7.4.0",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false, // Run tests sequentially for now (we're iterating on one flow)
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // One worker so we can watch logs easily
|
||||
reporter: [['html', { open: 'never' }], ['list']],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
// We expect both servers to be running already
|
||||
// (user will run ./scripts/dev.sh or npm run dev in another terminal)
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Trip {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
title String
|
||||
vehicleModel String
|
||||
rangeMi Int
|
||||
itinerary Json
|
||||
status String @default("planning")
|
||||
isPublic Boolean @default(false)
|
||||
shareSlug String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(uuid())
|
||||
tripId String
|
||||
role String
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
@@index([tripId])
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
|
||||
# One-command development script for Tesla Roadtrip
|
||||
# Usage: ./scripts/dev.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting Tesla Roadtrip development environment..."
|
||||
|
||||
# Load .env if it exists
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Check for XAI_API_KEY
|
||||
if [ -z "$XAI_API_KEY" ]; then
|
||||
echo ""
|
||||
echo "⚠️ XAI_API_KEY is not set."
|
||||
echo " → Real Grok (via xAI API) will NOT work."
|
||||
echo " → The app will fall back to very basic responses."
|
||||
echo ""
|
||||
echo " To fix this:"
|
||||
echo " 1. Add this line to your .env file:"
|
||||
echo " XAI_API_KEY=xai-YourKeyHere"
|
||||
echo ""
|
||||
echo " 2. Or export it before running:"
|
||||
echo " export XAI_API_KEY=xai-YourKeyHere"
|
||||
echo ""
|
||||
read -p "Continue without real Grok? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "✅ XAI_API_KEY found — real Grok API will be used"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Starting Backend + Frontend..."
|
||||
echo " Backend: http://localhost:3000"
|
||||
echo " Frontend: http://localhost:5173"
|
||||
echo ""
|
||||
|
||||
# Run both with nice colored labels
|
||||
npx concurrently \
|
||||
-n "BACKEND,FRONTEND" \
|
||||
-c "cyan,green" \
|
||||
--kill-others-on-fail \
|
||||
"npm run dev:server" \
|
||||
"npm --prefix client run dev"
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
export const env = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
appUrl: process.env.APP_URL || 'https://tesla-roadtrip.test',
|
||||
apiUrl: process.env.API_URL || 'https://tesla-roadtrip.test',
|
||||
|
||||
// Auth
|
||||
authSecret: process.env.AUTH_SECRET || '',
|
||||
authUrl: process.env.AUTH_URL || 'https://auth.tony.codes',
|
||||
|
||||
// Grok / xAI
|
||||
grokBin: process.env.GROK_BIN || '/usr/local/bin/grok',
|
||||
xaiApiKey: process.env.XAI_API_KEY || '',
|
||||
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
||||
} as const;
|
||||
@@ -0,0 +1,172 @@
|
||||
[
|
||||
{
|
||||
"id": "sc-london-heathrow",
|
||||
"name": "London Heathrow",
|
||||
"lat": 51.4706,
|
||||
"lng": -0.4543,
|
||||
"stalls": 24,
|
||||
"maxKw": 250,
|
||||
"country": "UK",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-london-battersea",
|
||||
"name": "London Battersea",
|
||||
"lat": 51.477,
|
||||
"lng": -0.17,
|
||||
"stalls": 12,
|
||||
"maxKw": 250,
|
||||
"country": "UK",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-folkestone",
|
||||
"name": "Folkestone Eurotunnel",
|
||||
"lat": 51.093,
|
||||
"lng": 1.155,
|
||||
"stalls": 8,
|
||||
"maxKw": 250,
|
||||
"country": "UK",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-birmingham",
|
||||
"name": "Birmingham",
|
||||
"lat": 52.4862,
|
||||
"lng": -1.8904,
|
||||
"stalls": 16,
|
||||
"maxKw": 250,
|
||||
"country": "UK",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-manchester",
|
||||
"name": "Manchester",
|
||||
"lat": 53.4808,
|
||||
"lng": -2.2426,
|
||||
"stalls": 12,
|
||||
"maxKw": 250,
|
||||
"country": "UK",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-edinburgh",
|
||||
"name": "Edinburgh",
|
||||
"lat": 55.9533,
|
||||
"lng": -3.1883,
|
||||
"stalls": 10,
|
||||
"maxKw": 250,
|
||||
"country": "UK",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-glasgow",
|
||||
"name": "Glasgow",
|
||||
"lat": 55.8642,
|
||||
"lng": -4.2518,
|
||||
"stalls": 8,
|
||||
"maxKw": 250,
|
||||
"country": "UK",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-calais",
|
||||
"name": "Calais",
|
||||
"lat": 50.9513,
|
||||
"lng": 1.8563,
|
||||
"stalls": 20,
|
||||
"maxKw": 250,
|
||||
"country": "FR",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-paris",
|
||||
"name": "Paris South",
|
||||
"lat": 48.8566,
|
||||
"lng": 2.3522,
|
||||
"stalls": 18,
|
||||
"maxKw": 250,
|
||||
"country": "FR",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-lyon",
|
||||
"name": "Lyon",
|
||||
"lat": 45.764,
|
||||
"lng": 4.8357,
|
||||
"stalls": 12,
|
||||
"maxKw": 250,
|
||||
"country": "FR",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-amsterdam",
|
||||
"name": "Amsterdam",
|
||||
"lat": 52.3676,
|
||||
"lng": 4.9041,
|
||||
"stalls": 14,
|
||||
"maxKw": 250,
|
||||
"country": "NL",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-brussels",
|
||||
"name": "Brussels",
|
||||
"lat": 50.8503,
|
||||
"lng": 4.3517,
|
||||
"stalls": 10,
|
||||
"maxKw": 250,
|
||||
"country": "BE",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-cologne",
|
||||
"name": "Cologne",
|
||||
"lat": 50.9375,
|
||||
"lng": 6.9603,
|
||||
"stalls": 12,
|
||||
"maxKw": 250,
|
||||
"country": "DE",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-frankfurt",
|
||||
"name": "Frankfurt",
|
||||
"lat": 50.1109,
|
||||
"lng": 8.6821,
|
||||
"stalls": 16,
|
||||
"maxKw": 250,
|
||||
"country": "DE",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-munich",
|
||||
"name": "Munich",
|
||||
"lat": 48.1351,
|
||||
"lng": 11.582,
|
||||
"stalls": 14,
|
||||
"maxKw": 250,
|
||||
"country": "DE",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-zurich",
|
||||
"name": "Zurich",
|
||||
"lat": 47.3769,
|
||||
"lng": 8.5417,
|
||||
"stalls": 8,
|
||||
"maxKw": 250,
|
||||
"country": "CH",
|
||||
"status": "open"
|
||||
},
|
||||
{
|
||||
"id": "sc-madrid",
|
||||
"name": "Madrid",
|
||||
"lat": 40.4168,
|
||||
"lng": -3.7038,
|
||||
"stalls": 12,
|
||||
"maxKw": 250,
|
||||
"country": "ES",
|
||||
"status": "open"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors({ origin: env.appUrl, credentials: true }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
if (req.url !== '/health') logger.info({ method: req.method, url: req.url }, 'request');
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', service: 'tesla-roadtrip', time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// TODO: Mount auth middleware + routes here once client is registered
|
||||
// TODO: Mount /api/trips and chat routes
|
||||
|
||||
app.listen(env.port, () => {
|
||||
logger.info(`Tesla Roadtrip server running on port ${env.port}`);
|
||||
});
|
||||
|
||||
// Chat routes (real Grok integration)
|
||||
import chatRoutes from './routes/chat.js';
|
||||
app.use('/api', chatRoutes);
|
||||
@@ -0,0 +1,11 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
|
||||
transport: process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true } }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Helper used across the server (consistent with other projects)
|
||||
export const createLogger = (module: string) => logger.child({ module });
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { grok } from '../services/llm/GrokHeadlessClient.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const log = createLogger('chat-api');
|
||||
const router = Router();
|
||||
|
||||
const ChatRequestSchema = z.object({
|
||||
message: z.string().min(1).max(2000),
|
||||
vehicle: z.object({ name: z.string(), rangeKm: z.number() }),
|
||||
itinerary: z.any().optional(),
|
||||
history: z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })).optional(),
|
||||
});
|
||||
|
||||
router.post('/chat', async (req, res) => {
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
const start = Date.now();
|
||||
|
||||
log.info({ requestId, body: req.body }, '=== INCOMING /api/chat REQUEST ===');
|
||||
|
||||
try {
|
||||
const parsed = ChatRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
log.error({ requestId, errors: parsed.error.format() }, 'Invalid request body');
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
}
|
||||
|
||||
const { message, vehicle, itinerary, history = [] } = parsed.data;
|
||||
|
||||
log.info({
|
||||
requestId,
|
||||
userMessage: message,
|
||||
vehicle: vehicle.name,
|
||||
historyLength: history.length,
|
||||
currentItineraryDays: itinerary?.days?.length || 0,
|
||||
}, 'Parsed chat request');
|
||||
|
||||
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
|
||||
const result = await grok.chat(
|
||||
[...history, { role: 'user' as const, content: message }],
|
||||
itinerary,
|
||||
vehicle
|
||||
);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
const payload: any = { reply: result.text };
|
||||
if (result.updatedItinerary) {
|
||||
payload.itinerary = result.updatedItinerary;
|
||||
}
|
||||
|
||||
log.info({
|
||||
requestId,
|
||||
durationMs: duration,
|
||||
replyLength: result.text.length,
|
||||
itineraryUpdated: !!result.updatedItinerary,
|
||||
newDays: result.updatedItinerary?.days?.length || 0,
|
||||
}, '=== SENDING RESPONSE TO FRONTEND ===');
|
||||
|
||||
if (result.updatedItinerary) {
|
||||
log.debug({ requestId, fullItinerary: result.updatedItinerary }, 'Full updated itinerary being sent');
|
||||
}
|
||||
|
||||
res.json(payload);
|
||||
} catch (err) {
|
||||
log.error({ requestId, err }, 'Chat route crashed');
|
||||
res.status(500).json({ reply: "Something went wrong on the server." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tesla Roadtrip — Grok Headless Client (with real xAI API fallback)
|
||||
* Maximum logging + strict structured output for map rendering
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createLogger } from '../../lib/logger.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const log = createLogger('grok-headless');
|
||||
const SENTINEL = 'ITINERARY_UPDATE:';
|
||||
|
||||
export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; }
|
||||
export interface GrokResponse { text: string; updatedItinerary?: any; }
|
||||
|
||||
export class GrokHeadlessClient {
|
||||
private useFallback = !!env.xaiApiKey || !env.grokEnabled;
|
||||
|
||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: string) {
|
||||
return `You are Grok Drive — an expert Tesla road trip planner for the UK and Europe.
|
||||
|
||||
Current vehicle: ${vehicle}
|
||||
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
|
||||
|
||||
CRITICAL OUTPUT RULES (must follow exactly):
|
||||
- When the user gives a clear origin and destination, immediately create a reasonable first-draft multi-day itinerary.
|
||||
- For every stop in the itinerary (Superchargers, hotels, attractions, etc.), you **MUST** include real latitude and longitude.
|
||||
Use accurate coordinates for real Tesla Superchargers (examples: London Battersea ≈ 51.477, -0.17; Birmingham NEC ≈ 52.45, -1.72; Leeds Skelton Lake ≈ 53.78, -1.46).
|
||||
- The output must contain the itinerary in this **exact JSON shape** after your normal reply:
|
||||
|
||||
ITINERARY_UPDATE:
|
||||
{
|
||||
"days": [
|
||||
{
|
||||
"day": 1,
|
||||
"stops": [
|
||||
{
|
||||
"id": "unique-string",
|
||||
"name": "Human readable name",
|
||||
"type": "supercharger" | "hotel" | "attraction" | "restaurant" | "custom",
|
||||
"lat": 51.477,
|
||||
"lng": -0.17,
|
||||
"day": 1,
|
||||
"order": 1,
|
||||
"estArrivalBattery": 25,
|
||||
"chargeMinutes": 25,
|
||||
"notes": "optional short note"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalDistanceKm": 650,
|
||||
"estDriveHours": 10.5,
|
||||
"estChargeHours": 1.5,
|
||||
"superchargers": 3,
|
||||
"hotels": 1
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Never mention the JSON in your spoken reply.
|
||||
- Make realistic assumptions for a first draft (max 5-6h driving/day, include major Superchargers, logical overnights).
|
||||
- After the draft, ask what the user wants to change.
|
||||
|
||||
Conversation:
|
||||
${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
|
||||
|
||||
ASSISTANT:`;
|
||||
}
|
||||
|
||||
async chat(messages: ChatMessage[], itinerary: any, vehicle: string): Promise<GrokResponse> {
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
log.info({ requestId, vehicle: vehicle.name, messageCount: messages.length }, '=== NEW CHAT REQUEST ===');
|
||||
|
||||
if (env.xaiApiKey) {
|
||||
log.info({ requestId }, 'Using real xAI API');
|
||||
return this.callXaiApi(messages, itinerary, vehicle, requestId);
|
||||
}
|
||||
|
||||
if (this.useFallback) {
|
||||
return this.dumbFallback(messages, requestId);
|
||||
}
|
||||
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle);
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
|
||||
|
||||
try {
|
||||
const args = ['-p', prompt, '--output-format', 'json', '--yolo', '--disallowed-tools', 'run_terminal_cmd,search_replace,write_file,Agent', '--tools', 'web_search,web_fetch', '--max-turns', '6', '--effort', 'high', '--cwd', tmp];
|
||||
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(env.grokBin, args, { cwd: tmp, env: { ...process.env } });
|
||||
let stdout = '';
|
||||
child.stdout.on('data', d => stdout += d);
|
||||
child.on('close', code => code === 0 ? resolve(stdout) : reject(new Error(`grok exited ${code}`)));
|
||||
});
|
||||
|
||||
const data = JSON.parse(result);
|
||||
const rawText = data.text || '';
|
||||
|
||||
const { cleanText, itinerary: parsed } = this.extractItineraryUpdate(rawText);
|
||||
|
||||
return { text: cleanText, updatedItinerary: parsed };
|
||||
|
||||
} catch (err) {
|
||||
log.error({ requestId, err }, 'Local grok CLI failed — falling back to xAI API');
|
||||
if (env.xaiApiKey) {
|
||||
return this.callXaiApi(messages, itinerary, vehicle, requestId);
|
||||
}
|
||||
return this.dumbFallback(messages, requestId);
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: string, requestId: string): Promise<GrokResponse> {
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle);
|
||||
|
||||
log.info({ requestId, promptLength: prompt.length }, 'Calling real xAI API');
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.x.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${env.xaiApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'grok-3',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
log.error({ requestId, status: response.status }, 'xAI API error');
|
||||
return this.dumbFallback(messages, requestId);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawText = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
const { cleanText, itinerary: parsed } = this.extractItineraryUpdate(rawText);
|
||||
|
||||
return { text: cleanText, updatedItinerary: parsed };
|
||||
} catch (err) {
|
||||
log.error({ requestId, err }, 'xAI API call failed');
|
||||
return this.dumbFallback(messages, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private extractItineraryUpdate(text: string): { cleanText: string; itinerary: any | null } {
|
||||
const upperText = text.toUpperCase();
|
||||
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
|
||||
|
||||
if (sentinelIndex === -1) {
|
||||
return { cleanText: text.trim(), itinerary: null };
|
||||
}
|
||||
|
||||
const after = text.substring(sentinelIndex + SENTINEL.length).trim();
|
||||
|
||||
// Try to find a JSON object, even if wrapped in ```json
|
||||
let jsonStart = after.indexOf('{');
|
||||
if (jsonStart === -1) return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
|
||||
// Find matching closing brace
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
|
||||
for (let i = jsonStart; i < after.length; i++) {
|
||||
const ch = after[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === '\\') { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (!inString) {
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) { end = i; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (end === -1) return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
|
||||
const jsonStr = after.substring(jsonStart, end + 1);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const cleanText = text.substring(0, sentinelIndex).trim();
|
||||
return { cleanText, itinerary: parsed };
|
||||
} catch (e) {
|
||||
log.error({ err: e }, 'Failed to parse ITINERARY_UPDATE JSON');
|
||||
return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async dumbFallback(messages: ChatMessage[], requestId: string): Promise<GrokResponse> {
|
||||
const last = messages[messages.length - 1]?.content.toLowerCase() || '';
|
||||
if (['hi', 'hello', 'hey'].some(g => last.includes(g))) {
|
||||
return { text: "Hello! I'm Grok Drive. How can I help plan your UK or European Tesla trip today?", updatedItinerary: null };
|
||||
}
|
||||
return {
|
||||
text: "I'm ready to plan a great Tesla route for you across the UK and Europe. Tell me where you want to go!",
|
||||
updatedItinerary: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const grok = new GrokHeadlessClient();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Grok API Diagnostic (Backend Only)', () => {
|
||||
|
||||
test('backend should successfully call xAI Grok and return a response', async ({ request }) => {
|
||||
test.setTimeout(120000); // Grok can be slow
|
||||
|
||||
const payload = {
|
||||
message: "Plan a short day trip from Milton Keynes to Telford in a Model Y. Include one Supercharger stop.",
|
||||
vehicle: { name: "Model Y Long Range", rangeKm: 514 },
|
||||
itinerary: null,
|
||||
history: []
|
||||
};
|
||||
|
||||
const response = await request.post('http://localhost:3000/api/chat', {
|
||||
data: payload,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
console.log('[Diagnostic] Grok reply length:', body.reply?.length);
|
||||
console.log('[Diagnostic] Has itinerary update:', !!body.itinerary);
|
||||
|
||||
expect(body.reply).toBeDefined();
|
||||
expect(body.reply.length).toBeGreaterThan(50);
|
||||
|
||||
// It should NOT be the generic error message
|
||||
expect(body.reply).not.toContain('having trouble reaching Grok');
|
||||
|
||||
// If it produced an itinerary, validate basic shape
|
||||
if (body.itinerary) {
|
||||
expect(body.itinerary.days).toBeDefined();
|
||||
expect(Array.isArray(body.itinerary.days)).toBe(true);
|
||||
expect(body.itinerary.days.length).toBeGreaterThan(0);
|
||||
console.log('[Diagnostic] Itinerary days:', body.itinerary.days.length);
|
||||
}
|
||||
|
||||
console.log('✅ Backend successfully called real Grok!');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Tesla Roadtrip - Full Chat to Map Flow', () => {
|
||||
|
||||
test.setTimeout(180000);
|
||||
|
||||
test('should send a trip request, get real Grok response, and render itinerary + map', async ({ page }) => {
|
||||
await page.goto('http://localhost:5173');
|
||||
|
||||
// Better header selector
|
||||
const grokHeader = page.locator('div.font-semibold.text-lg.tracking-tight:has-text("Grok Drive")').first();
|
||||
await expect(grokHeader).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const chatInput = page.locator('input[placeholder*="Tell me where you want to drive"], input[placeholder*="drive"]');
|
||||
await expect(chatInput).toBeVisible();
|
||||
|
||||
const tripRequest = 'I want to go from Milton Keynes to Telford in my Model Y';
|
||||
await chatInput.fill(tripRequest);
|
||||
|
||||
const sendButton = page.locator('button:has(svg)').last();
|
||||
await sendButton.click();
|
||||
|
||||
// Wait for thinking state
|
||||
const thinking = page.locator('text=GROK IS PLANNING YOUR ROUTE');
|
||||
await thinking.waitFor({ state: 'visible', timeout: 20000 }).catch(() => {});
|
||||
|
||||
// Wait for Grok to finish
|
||||
await thinking.waitFor({ state: 'hidden', timeout: 120000 });
|
||||
|
||||
const lastAssistant = page.locator('.chat-bubble-assistant').last();
|
||||
|
||||
// === DIAGNOSTIC: Detect the common failure mode early ===
|
||||
const messageText = await lastAssistant.textContent();
|
||||
if (messageText?.includes('having trouble reaching Grok')) {
|
||||
await page.screenshot({ path: `test-results/grok-failed-${Date.now()}.png`, fullPage: true });
|
||||
throw new Error('Grok call failed (got error message). Check backend logs + make sure XAI_API_KEY is loaded correctly (use ./scripts/dev.sh).');
|
||||
}
|
||||
|
||||
// Success path
|
||||
const dayOne = page.locator('text=DAY 1').first();
|
||||
await expect(dayOne).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const stop = page.locator('.group:has-text("Supercharger"), .group:has-text("Hotel")').first();
|
||||
await expect(stop).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await page.screenshot({
|
||||
path: `test-results/success-milton-keynes-telford-${Date.now()}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
console.log('✅ Full flow succeeded: Real Grok itinerary rendered on map!');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tesla Roadtrip • Grok Drive — UI Preview</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--tesla-red: #E82127;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system_ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: 'Space Grotesk', 'Inter', system_ui, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tesla-red { color: #E82127; }
|
||||
.bg-tesla-red { background-color: #E82127; }
|
||||
|
||||
.chat-bubble-user {
|
||||
background: #E82127;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-assistant {
|
||||
background: #1f242e;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
background: #0a0f1a;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
}
|
||||
|
||||
.tesla-marker {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
border: 2px solid white;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.stop-row {
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.stop-row:hover {
|
||||
background-color: #22283a;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-white">
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
|
||||
<!-- LEFT: CHAT -->
|
||||
<div class="w-[380px] flex flex-col border-r border-white/10 bg-[#111111]">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-5 py-4 border-b border-white/10 flex items-center justify-between bg-black/60">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full bg-[#E82127] flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-[17px] tracking-[-0.4px]">Grok Drive</div>
|
||||
<div class="text-[10px] text-white/50 -mt-0.5">UK & Europe • Headless Grok</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[10px] px-2.5 py-0.5 rounded bg-white/5 font-mono tracking-widest text-white/60">v0.1</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Selector -->
|
||||
<div class="px-4 py-3 border-b border-white/10 bg-black/30">
|
||||
<div class="text-[10px] uppercase tracking-[1.5px] text-white/40 mb-1.5 px-1">YOUR VEHICLE</div>
|
||||
<select class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-[9px] text-sm focus:outline-none focus:border-[#E82127]">
|
||||
<option>Model Y Long Range — 514 km</option>
|
||||
<option>Model 3 Highland LR — 549 km</option>
|
||||
<option>Model Y RWD (EU) — 455 km</option>
|
||||
<option>Model S Long Range — 634 km</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-6 text-sm" id="chat-messages">
|
||||
|
||||
<!-- Assistant message -->
|
||||
<div class="flex">
|
||||
<div class="max-w-[85%] chat-bubble-assistant rounded-2xl px-4 py-3 leading-snug">
|
||||
Hello! I'm Grok Drive, your Tesla trip planner for the UK and Europe. Where would you like to go? I know the Supercharger network across Britain, France, Germany, and beyond extremely well.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User message -->
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[82%] chat-bubble-user rounded-2xl px-4 py-3 leading-snug">
|
||||
Plan a 2-day trip from London to Edinburgh in my Model Y
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assistant reply -->
|
||||
<div class="flex">
|
||||
<div class="max-w-[85%] chat-bubble-assistant rounded-2xl px-4 py-3 leading-snug">
|
||||
Excellent choice — London to Edinburgh is one of the most popular Tesla routes in the UK. I've routed you via the excellent M1/A1 corridor with fast V3/V4 stalls. Battery stays healthy throughout.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Quick Prompts -->
|
||||
<div class="px-3 pb-2 flex flex-wrap gap-1.5 border-t border-white/10 pt-3 bg-black/40">
|
||||
<div onclick="useQuickPrompt(this)" class="cursor-pointer text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition">Plan a 2-day trip from London to Edinburgh</div>
|
||||
<div onclick="useQuickPrompt(this)" class="cursor-pointer text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition">Add a lunch stop in the Lake District</div>
|
||||
<div onclick="useQuickPrompt(this)" class="cursor-pointer text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition">Find a hotel with destination charging</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="p-3 border-t border-white/10 bg-black/40">
|
||||
<div class="flex items-center gap-2 bg-[#1a1a1a] border border-white/10 rounded-2xl px-3 focus-within:border-[#E82127]">
|
||||
<input id="chat-input"
|
||||
type="text"
|
||||
placeholder="Where would you like to drive in the UK or Europe?"
|
||||
class="flex-1 bg-transparent py-3 text-sm placeholder:text-white/40 outline-none"
|
||||
onkeypress="if(event.key === 'Enter') sendMessage()">
|
||||
<button onclick="sendMessage()"
|
||||
class="p-2 rounded-xl bg-[#E82127] hover:bg-[#c01a20] active:scale-95 transition flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-center text-[9px] text-white/30 mt-2 tracking-[1.5px]">POWERED BY HEADLESS GROK CLI</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: MAP + ITINERARY -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="h-14 border-b border-white/10 bg-black/40 px-6 flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-8 nav-text">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[#E82127]"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314-11.314z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg></span>
|
||||
<span class="font-semibold">665</span>
|
||||
<span class="text-white/50">km</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[#E82127]"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 01-9 9 9 9 0 01-9-9 9 9 0 019-9 9 9 0 019 9z" /></svg></span>
|
||||
<span class="font-semibold">7.2</span>
|
||||
<span class="text-white/50">hrs drive</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[#E82127]"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg></span>
|
||||
<span class="font-semibold">1.1</span>
|
||||
<span class="text-white/50">hrs charging</span>
|
||||
</div>
|
||||
<div class="text-white/50">• 3 Superchargers • 1 hotel</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="alert('GPX would be downloaded in the real app')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-white/20 rounded-lg hover:bg-white/5 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 13v6m0 0l-3-3m3 3l3-3m-6-4v1m0 0l-3-3m3 3l3-3" /></svg>
|
||||
GPX
|
||||
</button>
|
||||
<button onclick="alert('Share link copied in the real app')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 rounded-lg transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 01-2.25 2.25" /></svg>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAP -->
|
||||
<div class="flex-1 p-3 bg-[#05070d]">
|
||||
<div class="w-full h-full rounded-2xl overflow-hidden border border-white/10 relative" id="map-wrapper">
|
||||
<!-- Real Leaflet will be initialized here -->
|
||||
<div id="map" style="width: 100%; height: 100%; background: #0a0f1a;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Itinerary -->
|
||||
<div class="h-[215px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto">
|
||||
<div class="flex gap-6 min-w-max">
|
||||
|
||||
<!-- Day 1 -->
|
||||
<div class="w-[310px]">
|
||||
<div class="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">DAY 1 • London → Edinburgh</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<!-- Stop -->
|
||||
<div class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-[#E82127] flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="font-medium">London Battersea Supercharger</div>
|
||||
<div class="text-xs text-white/40">Arrive 28% • 22 min charge</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-[#E82127] flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="font-medium">Birmingham Supercharger</div>
|
||||
<div class="text-xs text-white/40">Arrive 19% • 28 min charge</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-blue-500 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="font-medium">The Balmoral Hotel, Edinburgh</div>
|
||||
<div class="text-xs text-white/40">Destination charging • 4 nights</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
|
||||
</div>
|
||||
|
||||
<!-- Day 2 -->
|
||||
<div class="w-[310px]">
|
||||
<div class="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">DAY 2 • Edinburgh & Return</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-white/60 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="font-medium">Edinburgh Castle</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-[#E82127] flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="font-medium">Glasgow Supercharger</div>
|
||||
<div class="text-xs text-white/40">Arrive 24% • 19 min charge</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
// Tailwind script
|
||||
function initializeTailwind() {
|
||||
document.documentElement.style.setProperty('--accent', '#E82127');
|
||||
}
|
||||
|
||||
// Initialize Leaflet Map (Europe / UK view)
|
||||
function initMap() {
|
||||
const map = L.map('map', {
|
||||
zoomControl: false,
|
||||
attributionControl: false
|
||||
}).setView([54.8, -2.8], 5.4);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>', subdomains: 'abcd', maxZoom: 20
|
||||
}).addTo(map);
|
||||
|
||||
// Stops for London → Edinburgh route
|
||||
const stops = [
|
||||
{ id: 1, name: "London Battersea", lat: 51.477, lng: -0.17, type: "supercharger" },
|
||||
{ id: 2, name: "Birmingham", lat: 52.486, lng: -1.89, type: "supercharger" },
|
||||
{ id: 3, name: "The Balmoral Hotel", lat: 55.953, lng: -3.188, type: "hotel" },
|
||||
{ id: 4, name: "Edinburgh Castle", lat: 55.9486, lng: -3.2008, type: "attraction" }
|
||||
];
|
||||
|
||||
// Add markers
|
||||
stops.forEach(stop => {
|
||||
const color = stop.type === 'supercharger' ? '#E82127' :
|
||||
stop.type === 'hotel' ? '#3b82f6' : '#64748b';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `<div style="background:${color}; width:22px; height:22px; border-radius:9999px; border:2px solid white; display:flex; align-items:center; justify-content:center; box-shadow:0 2px 6px rgba(0,0,0,0.3);">
|
||||
<span style="color:white; font-size:10px; font-weight:700;">${stop.type === 'supercharger' ? '⚡' : '●'}</span>
|
||||
</div>`,
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11]
|
||||
});
|
||||
|
||||
L.marker([stop.lat, stop.lng], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup(`<b>${stop.name}</b><br><span style="color:#888">${stop.type}</span>`);
|
||||
});
|
||||
|
||||
// Draw route polyline
|
||||
const routeCoords = stops.map(s => [s.lat, s.lng]);
|
||||
L.polyline(routeCoords, {
|
||||
color: '#E82127',
|
||||
weight: 4,
|
||||
opacity: 0.7,
|
||||
lineJoin: 'round'
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
if (!input.value.trim()) return;
|
||||
|
||||
const messagesContainer = document.getElementById('chat-messages');
|
||||
|
||||
// Add user message
|
||||
const userMsg = document.createElement('div');
|
||||
userMsg.className = 'flex justify-end';
|
||||
userMsg.innerHTML = `
|
||||
<div class="max-w-[82%] chat-bubble-user rounded-2xl px-4 py-3 leading-snug">
|
||||
${input.value}
|
||||
</div>
|
||||
`;
|
||||
messagesContainer.appendChild(userMsg);
|
||||
|
||||
const userText = input.value;
|
||||
input.value = '';
|
||||
|
||||
// Simulate thinking
|
||||
setTimeout(() => {
|
||||
const thinking = document.createElement('div');
|
||||
thinking.className = 'flex items-center gap-2 pl-1 text-[#E82127]';
|
||||
thinking.innerHTML = `
|
||||
<div class="flex gap-1">
|
||||
<div class="w-1 h-1 bg-current rounded-full animate-bounce"></div>
|
||||
<div class="w-1 h-1 bg-current rounded-full animate-bounce" style="animation-delay:120ms"></div>
|
||||
<div class="w-1 h-1 bg-current rounded-full animate-bounce" style="animation-delay:240ms"></div>
|
||||
</div>
|
||||
<span class="text-xs tracking-widest">GROK IS PLANNING...</span>
|
||||
`;
|
||||
messagesContainer.appendChild(thinking);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
|
||||
setTimeout(() => {
|
||||
thinking.remove();
|
||||
|
||||
const reply = document.createElement('div');
|
||||
reply.className = 'flex';
|
||||
reply.innerHTML = `
|
||||
<div class="max-w-[85%] chat-bubble-assistant rounded-2xl px-4 py-3 leading-snug">
|
||||
I've added a great lunch stop at Tebay Services in the Lake District. Many Tesla owners rate it as one of the best on this route.
|
||||
</div>
|
||||
`;
|
||||
messagesContainer.appendChild(reply);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
|
||||
// Simulate itinerary update
|
||||
setTimeout(() => {
|
||||
alert("In the real app, this would dynamically add a new stop to the map and itinerary panel.");
|
||||
}, 800);
|
||||
}, 1400);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
function useQuickPrompt(el) {
|
||||
const text = el.textContent;
|
||||
const input = document.getElementById('chat-input');
|
||||
input.value = text;
|
||||
|
||||
// Trigger send
|
||||
setTimeout(() => {
|
||||
sendMessage();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function removeStop(btn) {
|
||||
btn.closest('.group').style.transition = 'all 0.2s';
|
||||
btn.closest('.group').style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
btn.closest('.group').remove();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
// Boot everything
|
||||
function init() {
|
||||
initializeTailwind();
|
||||
initMap();
|
||||
|
||||
// Keyboard hint
|
||||
const input = document.getElementById('chat-input');
|
||||
input.addEventListener('focus', () => {
|
||||
input.placeholder = "Try: 'Add a stop near the Lake District'";
|
||||
});
|
||||
}
|
||||
|
||||
window.onload = init;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
echo "High-fidelity static UI preview created"
|
||||
Reference in New Issue
Block a user