feat: travel dates + sea-crossing chooser, Tesla in-car polish, Fleet API stub
- Travel dates: TopBar chip + popover (outbound/return/travellers); sent to Grok prompt; itinerary.needsTravelDates drives a nudge banner; cache and prefetch ledger invalidate when dates change - Sea crossings: CrossingOption schema (Eurotunnel, DFDS, P&O, Brittany, Stena Line); CrossingSwapBlock under tunnel/ferry/crossing stops with trip-impact deltas and Book links; prompt requires 3-5 real options for every UK ↔ mainland route; picking a crossing triggers silent re-plan - Tesla in-car polish: UA + heuristic detection sets <html class="incar">; CSS overrides kill backdrop-filter, scale fonts, enforce 44px tap targets, disable hover flicker; geolocation + reverse geocode + crosshair button inside the From input; up/down arrow reorder buttons replace touch-broken HTML5 drag-and-drop - Tesla Fleet API stub: /.well-known/appspecific/com.tesla.3p.public-key.pem served from TESLA_FLEET_PUBLIC_KEY for partner domain verification; OAuth callback + vehicle_data stub return 503 until partner approval - Dockerfile + .dockerignore for Dokku deployment; server now serves client/dist in production
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
client/dist
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
ui-preview
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
# Multi-stage build: install + build the client and server, then run a slim
|
||||||
|
# node image that serves both from one process on $PORT (default 3000).
|
||||||
|
|
||||||
|
# ─── Stage 1: deps + build ──────────────────────────────────────────────────
|
||||||
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Root deps
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# Client deps
|
||||||
|
COPY client/package.json client/package-lock.json* ./client/
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm --prefix client ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# Source
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY server ./server
|
||||||
|
COPY client ./client
|
||||||
|
|
||||||
|
# Build client (vite → client/dist) and server (tsc → dist/server)
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Stage 2: runtime ───────────────────────────────────────────────────────
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Only ship the prod deps + built artefacts
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/client/dist ./client/dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/server/index.js"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Detect Tesla in-car browser + general touch-only large-screen mode.
|
||||||
|
// Tesla MCU2 reports a "QtCarBrowser" UA fragment; MCU3 reports a more standard
|
||||||
|
// Chromium UA but with "Tesla" in some firmware. We also honour ?incar=1 for
|
||||||
|
// testing on a regular browser, and fall back to a touch + landscape-tablet
|
||||||
|
// heuristic so the optimisations apply to anything that looks like a car dash.
|
||||||
|
|
||||||
|
export interface InCarInfo {
|
||||||
|
/** True if we're inside (or simulating) a Tesla in-car browser. */
|
||||||
|
isTesla: boolean;
|
||||||
|
/** True if we should apply the heavyweight "car dash" UX (big text, no blur, tap-friendly). */
|
||||||
|
isInCar: boolean;
|
||||||
|
/** Approximate MCU generation hint when detectable. */
|
||||||
|
mcu: 'mcu2' | 'mcu3' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectInCar(): InCarInfo {
|
||||||
|
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||||
|
return { isTesla: false, isInCar: false, mcu: 'unknown' };
|
||||||
|
}
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const isQt = /QtCarBrowser/i.test(ua);
|
||||||
|
const isTeslaUa = /Tesla/i.test(ua);
|
||||||
|
const forced = new URLSearchParams(window.location.search).get('incar') === '1';
|
||||||
|
const isTesla = isQt || isTeslaUa || forced;
|
||||||
|
|
||||||
|
// Heuristic touch-only tablet/dash mode: coarse pointer, ≥1200px width, no fine pointer.
|
||||||
|
const coarse = window.matchMedia?.('(pointer: coarse)').matches ?? false;
|
||||||
|
const wide = window.innerWidth >= 1200;
|
||||||
|
const heuristicInCar = coarse && wide;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTesla,
|
||||||
|
isInCar: isTesla || forced || heuristicInCar,
|
||||||
|
mcu: isQt ? 'mcu2' : isTeslaUa ? 'mcu3' : 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply or remove the body class side effects (idempotent). */
|
||||||
|
export function applyInCarClass(info: InCarInfo) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.toggle('incar', info.isInCar);
|
||||||
|
root.classList.toggle('tesla', info.isTesla);
|
||||||
|
root.classList.toggle('tesla-mcu2', info.mcu === 'mcu2');
|
||||||
|
root.classList.toggle('tesla-mcu3', info.mcu === 'mcu3');
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import { BrowserRouter } from 'react-router-dom';
|
|||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
import { detectInCar, applyInCarClass } from './lib/incar';
|
||||||
|
|
||||||
|
applyInCarClass(detectInCar());
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
|
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
|
||||||
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
|
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
|
||||||
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
|
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
|
||||||
|
CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Fix Leaflet default icons (we still need pins for non-active stops)
|
// Fix Leaflet default icons (we still need pins for non-active stops)
|
||||||
@@ -18,7 +19,7 @@ L.Icon.Default.mergeOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel';
|
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel' | 'ferry' | 'crossing';
|
||||||
|
|
||||||
interface NearbyPlace {
|
interface NearbyPlace {
|
||||||
category: 'food' | 'do' | 'see' | 'shop' | 'rest';
|
category: 'food' | 'do' | 'see' | 'shop' | 'rest';
|
||||||
@@ -39,6 +40,24 @@ interface ChargerOption {
|
|||||||
badge?: string | null;
|
badge?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CrossingOption {
|
||||||
|
id: string;
|
||||||
|
operator: string; // e.g. "Eurotunnel Le Shuttle", "DFDS", "P&O Ferries", "Brittany Ferries"
|
||||||
|
mode: 'tunnel' | 'ferry';
|
||||||
|
fromPort: string; // e.g. "Folkestone, UK"
|
||||||
|
toPort: string; // e.g. "Coquelles (Calais), FR"
|
||||||
|
durationMin: number;
|
||||||
|
priceEur: number; // ballpark single-vehicle one-way price
|
||||||
|
frequency?: string; // e.g. "every 30 min, 24/7"
|
||||||
|
pros?: string[];
|
||||||
|
cons?: string[];
|
||||||
|
badge?: 'Fastest' | 'Cheapest' | 'Most scenic' | 'Overnight' | 'Frequent' | null;
|
||||||
|
detourMin?: number; // vs the chosen crossing (in road-driving terms)
|
||||||
|
detourKm?: number;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
bookingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteVariant {
|
interface RouteVariant {
|
||||||
id: 'fast' | 'scenic' | 'cheap';
|
id: 'fast' | 'scenic' | 'cheap';
|
||||||
label: string;
|
label: string;
|
||||||
@@ -89,6 +108,13 @@ interface Stop {
|
|||||||
alternatives?: AlternativeStop[];
|
alternatives?: AlternativeStop[];
|
||||||
nearby?: NearbyPlace[];
|
nearby?: NearbyPlace[];
|
||||||
chargerOptions?: ChargerOption[];
|
chargerOptions?: ChargerOption[];
|
||||||
|
crossingOptions?: CrossingOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TravelDates {
|
||||||
|
outbound: string | null; // ISO yyyy-mm-dd
|
||||||
|
return: string | null; // ISO yyyy-mm-dd (null if one-way)
|
||||||
|
travellers?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Itinerary {
|
interface Itinerary {
|
||||||
@@ -101,6 +127,7 @@ interface Itinerary {
|
|||||||
hotels: number;
|
hotels: number;
|
||||||
highlights?: string[];
|
highlights?: string[];
|
||||||
};
|
};
|
||||||
|
needsTravelDates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Leg {
|
interface Leg {
|
||||||
@@ -116,7 +143,7 @@ const EMPTY_ITINERARY: Itinerary = {
|
|||||||
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0, highlights: [] },
|
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0, highlights: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel'];
|
const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel', 'ferry', 'crossing'];
|
||||||
|
|
||||||
interface VehicleTrim {
|
interface VehicleTrim {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -223,6 +250,8 @@ function stopMeta(type: StopType): { icon: React.ComponentType<{ className?: str
|
|||||||
case 'origin': return { icon: HomeIcon, color: '#9ca3af' };
|
case 'origin': return { icon: HomeIcon, color: '#9ca3af' };
|
||||||
case 'destination': return { icon: Flag, color: 'var(--gd-red)' };
|
case 'destination': return { icon: Flag, color: 'var(--gd-red)' };
|
||||||
case 'tunnel': return { icon: Route, color: '#a78bfa' };
|
case 'tunnel': return { icon: Route, color: '#a78bfa' };
|
||||||
|
case 'ferry':
|
||||||
|
case 'crossing': return { icon: Ship, color: '#60a5fa' };
|
||||||
case 'supercharger':
|
case 'supercharger':
|
||||||
case 'destination-charger': return { icon: Zap, color: '#4ade80' };
|
case 'destination-charger': return { icon: Zap, color: '#4ade80' };
|
||||||
case 'hotel': return { icon: Bed, color: '#60a5fa' };
|
case 'hotel': return { icon: Bed, color: '#60a5fa' };
|
||||||
@@ -256,6 +285,41 @@ async function geocodeLocation(query: string): Promise<{ lat: number; lng: numbe
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse geocode lat/lng → human-readable place name (Nominatim).
|
||||||
|
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=14`;
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data) return null;
|
||||||
|
const a = data.address || {};
|
||||||
|
// Build something like "Milton Keynes, MK7 8PJ" — town + postcode if available.
|
||||||
|
const town = a.city || a.town || a.village || a.hamlet || a.suburb || a.county || '';
|
||||||
|
const postcode = a.postcode || '';
|
||||||
|
const country = a.country_code ? a.country_code.toUpperCase() : '';
|
||||||
|
const parts = [town, postcode, country].filter(Boolean);
|
||||||
|
return parts.join(', ') || data.display_name || null;
|
||||||
|
} catch {
|
||||||
|
console.warn('[TeslaTrip] Reverse geocoding failed', lat, lng);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser geolocation wrapped in a promise with a short timeout — Tesla's
|
||||||
|
// in-car browser sometimes hangs forever waiting for a fix.
|
||||||
|
async function getBrowserLocation(timeoutMs = 8000): Promise<GeolocationCoordinates | null> {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
|
||||||
|
return new Promise<GeolocationCoordinates | null>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => { if (!settled) { settled = true; resolve(null); } }, timeoutMs);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => { if (!settled) { settled = true; clearTimeout(timer); resolve(pos.coords); } },
|
||||||
|
() => { if (!settled) { settled = true; clearTimeout(timer); resolve(null); } },
|
||||||
|
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 30_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Itinerary normalization (preserves all enrichment fields) ───────────────
|
// ─── Itinerary normalization (preserves all enrichment fields) ───────────────
|
||||||
async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
||||||
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
|
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
|
||||||
@@ -330,6 +394,28 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const cleanCrossings: CrossingOption[] = Array.isArray(s.crossingOptions)
|
||||||
|
? s.crossingOptions
|
||||||
|
.filter((c: any) => c && typeof c.operator === 'string')
|
||||||
|
.map((c: any): CrossingOption => ({
|
||||||
|
id: c.id || `crossing-${Date.now()}-${Math.random()}`,
|
||||||
|
operator: c.operator,
|
||||||
|
mode: c.mode === 'tunnel' ? 'tunnel' : 'ferry',
|
||||||
|
fromPort: typeof c.fromPort === 'string' ? c.fromPort : '',
|
||||||
|
toPort: typeof c.toPort === 'string' ? c.toPort : '',
|
||||||
|
durationMin: typeof c.durationMin === 'number' ? c.durationMin : 0,
|
||||||
|
priceEur: typeof c.priceEur === 'number' ? c.priceEur : 0,
|
||||||
|
frequency: typeof c.frequency === 'string' ? c.frequency : undefined,
|
||||||
|
pros: Array.isArray(c.pros) ? c.pros.filter((p: unknown) => typeof p === 'string') : undefined,
|
||||||
|
cons: Array.isArray(c.cons) ? c.cons.filter((p: unknown) => typeof p === 'string') : undefined,
|
||||||
|
badge: typeof c.badge === 'string' ? c.badge as CrossingOption['badge'] : null,
|
||||||
|
detourMin: typeof c.detourMin === 'number' ? c.detourMin : undefined,
|
||||||
|
detourKm: typeof c.detourKm === 'number' ? c.detourKm : undefined,
|
||||||
|
isCurrent: c.isCurrent === true,
|
||||||
|
bookingUrl: typeof c.bookingUrl === 'string' ? c.bookingUrl : undefined,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
const shared = {
|
const shared = {
|
||||||
estArrivalBattery: s.estArrivalBattery,
|
estArrivalBattery: s.estArrivalBattery,
|
||||||
chargeMinutes: s.chargeMinutes,
|
chargeMinutes: s.chargeMinutes,
|
||||||
@@ -343,6 +429,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
|||||||
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
|
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
|
||||||
nearby: cleanNearby.length > 0 ? cleanNearby : undefined,
|
nearby: cleanNearby.length > 0 ? cleanNearby : undefined,
|
||||||
chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined,
|
chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined,
|
||||||
|
crossingOptions: cleanCrossings.length > 0 ? cleanCrossings : undefined,
|
||||||
};
|
};
|
||||||
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
|
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
|
||||||
|
|
||||||
@@ -380,6 +467,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
|||||||
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
|
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
|
needsTravelDates: raw.needsTravelDates === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +504,7 @@ function normalizePartialItinerary(raw: any): Itinerary {
|
|||||||
alternatives: undefined, // skip during partial — final event populates
|
alternatives: undefined, // skip during partial — final event populates
|
||||||
nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined,
|
nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined,
|
||||||
chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined,
|
chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined,
|
||||||
|
crossingOptions: Array.isArray(s.crossingOptions) ? s.crossingOptions : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (validStops.length > 0) {
|
if (validStops.length > 0) {
|
||||||
@@ -440,6 +529,7 @@ function normalizePartialItinerary(raw: any): Itinerary {
|
|||||||
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
|
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
|
needsTravelDates: raw.needsTravelDates === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +607,28 @@ function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | nu
|
|||||||
return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`;
|
return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateShort(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(outbound: string | null | undefined, ret: string | null | undefined): string {
|
||||||
|
if (!outbound) return 'Add dates';
|
||||||
|
if (!ret) return `${formatDateShort(outbound)} · one-way`;
|
||||||
|
return `${formatDateShort(outbound)} → ${formatDateShort(ret)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nightsBetween(outbound: string | null | undefined, ret: string | null | undefined): number | null {
|
||||||
|
if (!outbound || !ret) return null;
|
||||||
|
const a = new Date(outbound);
|
||||||
|
const b = new Date(ret);
|
||||||
|
if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return null;
|
||||||
|
const diff = Math.round((b.getTime() - a.getTime()) / 86400000);
|
||||||
|
return diff > 0 ? diff : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Error Boundary ──────────────────────────────────────────────────────────
|
// ─── Error Boundary ──────────────────────────────────────────────────────────
|
||||||
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
|
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
|
||||||
constructor(props: any) { super(props); this.state = { hasError: false }; }
|
constructor(props: any) { super(props); this.state = { hasError: false }; }
|
||||||
@@ -870,13 +982,15 @@ function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPic
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
|
function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing }: {
|
||||||
stop: Stop;
|
stop: Stop;
|
||||||
onSwap: (alt: AlternativeStop) => void;
|
onSwap: (alt: AlternativeStop) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onCustomise: () => void;
|
onCustomise: () => void;
|
||||||
|
onPickCrossing?: (c: CrossingOption) => void;
|
||||||
}) {
|
}) {
|
||||||
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
|
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
|
||||||
|
const isCrossing = stop.type === 'crossing' || stop.type === 'tunnel' || stop.type === 'ferry';
|
||||||
const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null;
|
const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null;
|
||||||
const charge = typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 ? stop.chargeMinutes : null;
|
const charge = typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 ? stop.chargeMinutes : null;
|
||||||
const leave = arrive != null && charge != null ? Math.min(100, arrive + Math.round(charge * 1.1)) : null;
|
const leave = arrive != null && charge != null ? Math.min(100, arrive + Math.round(charge * 1.1)) : null;
|
||||||
@@ -884,6 +998,7 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
|
|||||||
const amenities = (stop.amenities || []).slice(0, 8);
|
const amenities = (stop.amenities || []).slice(0, 8);
|
||||||
const alts = stop.alternatives || [];
|
const alts = stop.alternatives || [];
|
||||||
const chargers = stop.chargerOptions || [];
|
const chargers = stop.chargerOptions || [];
|
||||||
|
const crossings = stop.crossingOptions || [];
|
||||||
const nearby = stop.nearby || [];
|
const nearby = stop.nearby || [];
|
||||||
|
|
||||||
const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all');
|
const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all');
|
||||||
@@ -914,6 +1029,16 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isCrossing && crossings.length > 0 && (
|
||||||
|
<div className="mb-3.5">
|
||||||
|
<SectionLabel>Crossing</SectionLabel>
|
||||||
|
<CrossingSwapBlock
|
||||||
|
options={crossings}
|
||||||
|
onPick={(c) => onPickCrossing?.(c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{nearby.length > 0 && (
|
{nearby.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -1064,7 +1189,8 @@ function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHo
|
|||||||
|
|
||||||
// ─── Stop card (icon-led) ────────────────────────────────────────────────────
|
// ─── Stop card (icon-led) ────────────────────────────────────────────────────
|
||||||
function StopCard({
|
function StopCard({
|
||||||
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise,
|
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, onPickCrossing,
|
||||||
|
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
|
||||||
onDragStart, onDragOver, onDrop, onDragEnd,
|
onDragStart, onDragOver, onDrop, onDragEnd,
|
||||||
}: {
|
}: {
|
||||||
stop: Stop;
|
stop: Stop;
|
||||||
@@ -1076,6 +1202,11 @@ function StopCard({
|
|||||||
onSwap: (alt: AlternativeStop) => void;
|
onSwap: (alt: AlternativeStop) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onCustomise: () => void;
|
onCustomise: () => void;
|
||||||
|
onPickCrossing?: (c: CrossingOption) => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
canMoveUp: boolean;
|
||||||
|
canMoveDown: boolean;
|
||||||
onDragStart: (e: React.DragEvent) => void;
|
onDragStart: (e: React.DragEvent) => void;
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
onDrop: (e: React.DragEvent) => void;
|
onDrop: (e: React.DragEvent) => void;
|
||||||
@@ -1085,7 +1216,8 @@ function StopCard({
|
|||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
|
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
|
||||||
const isSleep = stop.type === 'hotel';
|
const isSleep = stop.type === 'hotel';
|
||||||
const isTunnel = stop.type === 'tunnel';
|
const isCrossingStop = stop.type === 'tunnel' || stop.type === 'ferry' || stop.type === 'crossing';
|
||||||
|
const currentCrossing = isCrossingStop ? (stop.crossingOptions?.find(o => o.isCurrent) || stop.crossingOptions?.[0]) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1106,12 +1238,34 @@ function StopCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-[10px] grid place-items-center flex-shrink-0"
|
className="w-10 h-10 rounded-[10px] grid place-items-center"
|
||||||
style={{ background: `${meta.color}22` }}
|
style={{ background: `${meta.color}22` }}
|
||||||
>
|
>
|
||||||
<Icon size={18} className="" />
|
<Icon size={18} className="" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onMoveUp(); }}
|
||||||
|
disabled={!canMoveUp}
|
||||||
|
title="Move up"
|
||||||
|
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
|
||||||
|
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onMoveDown(); }}
|
||||||
|
disabled={!canMoveDown}
|
||||||
|
title="Move down"
|
||||||
|
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
|
||||||
|
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium text-[14px] truncate" style={{ letterSpacing: '-0.005em' }}>{stop.name}</div>
|
<div className="font-medium text-[14px] truncate" style={{ letterSpacing: '-0.005em' }}>{stop.name}</div>
|
||||||
@@ -1129,8 +1283,10 @@ function StopCard({
|
|||||||
{isSleep && (
|
{isSleep && (
|
||||||
<div className="text-[11px] num font-medium flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-blue)' }}>overnight</div>
|
<div className="text-[11px] num font-medium flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-blue)' }}>overnight</div>
|
||||||
)}
|
)}
|
||||||
{isTunnel && (
|
{isCrossingStop && currentCrossing && (
|
||||||
<div className="text-[11px] num flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-text-3)' }}>£89 · 35m</div>
|
<div className="text-[11px] num flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
€{Math.round(currentCrossing.priceEur)} · {formatDuration(currentCrossing.durationMin)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{stop.combo && (
|
{stop.combo && (
|
||||||
@@ -1142,7 +1298,7 @@ function StopCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[11.5px] mt-1" style={{ color: 'var(--gd-text-3)' }}>
|
<div className="text-[11.5px] mt-1" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
{stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : 'Stop')}
|
{stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : stop.type === 'tunnel' ? 'Eurotunnel · drive on/off' : stop.type === 'ferry' ? 'Ferry crossing' : stop.type === 'crossing' ? 'Sea crossing' : 'Stop')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!active && stop.description && (
|
{!active && stop.description && (
|
||||||
@@ -1151,7 +1307,7 @@ function StopCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} />}
|
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} onPickCrossing={onPickCrossing} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1200,6 +1356,7 @@ function TopBar({
|
|||||||
origin, destination, onOriginChange, onDestinationChange, onODCommit,
|
origin, destination, onOriginChange, onDestinationChange, onODCommit,
|
||||||
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
|
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
|
||||||
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
|
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
|
||||||
|
travelDates, onOpenDates, onUseMyLocation,
|
||||||
}: {
|
}: {
|
||||||
origin: string; destination: string;
|
origin: string; destination: string;
|
||||||
onOriginChange: (v: string) => void;
|
onOriginChange: (v: string) => void;
|
||||||
@@ -1211,7 +1368,19 @@ function TopBar({
|
|||||||
vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void;
|
vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void;
|
||||||
grokStatus: { label?: string };
|
grokStatus: { label?: string };
|
||||||
onOpenGpx: () => void;
|
onOpenGpx: () => void;
|
||||||
|
travelDates: TravelDates;
|
||||||
|
onOpenDates: (rect: DOMRect) => void;
|
||||||
|
onUseMyLocation: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [locating, setLocating] = React.useState(false);
|
||||||
|
const handleLocate = async () => {
|
||||||
|
setLocating(true);
|
||||||
|
try { await onUseMyLocation(); } finally { setLocating(false); }
|
||||||
|
};
|
||||||
|
const datesLabel = travelDates.outbound
|
||||||
|
? formatDateRange(travelDates.outbound, travelDates.return)
|
||||||
|
: 'Add dates';
|
||||||
|
const datesEmpty = !travelDates.outbound;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-4 px-5 flex-shrink-0"
|
className="flex items-center gap-4 px-5 flex-shrink-0"
|
||||||
@@ -1249,6 +1418,20 @@ function TopBar({
|
|||||||
className="bg-transparent border-none outline-none text-[13px] w-full"
|
className="bg-transparent border-none outline-none text-[13px] w-full"
|
||||||
style={{ color: 'var(--gd-text)' }}
|
style={{ color: 'var(--gd-text)' }}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLocate}
|
||||||
|
disabled={locating}
|
||||||
|
title="Use my current location"
|
||||||
|
className="grid place-items-center rounded-md flex-shrink-0 disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26,
|
||||||
|
background: 'var(--gd-panel-2)',
|
||||||
|
border: '1px solid var(--gd-border)',
|
||||||
|
color: 'var(--gd-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Crosshair className={`w-3.5 h-3.5 ${locating ? 'animate-pulse' : ''}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3.5 flex items-center gap-2 h-full flex-1">
|
<div className="px-3.5 flex items-center gap-2 h-full flex-1">
|
||||||
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
|
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
|
||||||
@@ -1326,6 +1509,20 @@ function TopBar({
|
|||||||
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
|
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => onOpenDates(e.currentTarget.getBoundingClientRect())}
|
||||||
|
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: datesEmpty ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
|
||||||
|
border: `1px solid ${datesEmpty ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
|
||||||
|
color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-3.5 h-3.5" />
|
||||||
|
<div className="text-[12px] font-medium">{datesLabel}</div>
|
||||||
|
<ChevronDown className="w-3 h-3" style={{ color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text-3)' }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<ChipButton onClick={() => onOpenGpx()}>
|
<ChipButton onClick={() => onOpenGpx()}>
|
||||||
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||||
Export
|
Export
|
||||||
@@ -1362,6 +1559,9 @@ export default function TeslaTripPlanner() {
|
|||||||
const [hoverStopId, setHoverStopId] = useState<string | null>(null);
|
const [hoverStopId, setHoverStopId] = useState<string | null>(null);
|
||||||
const [origin, setOrigin] = useState('');
|
const [origin, setOrigin] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
|
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
|
||||||
|
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||||
|
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
|
||||||
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
|
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
|
||||||
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
||||||
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
||||||
@@ -1436,6 +1636,21 @@ export default function TeslaTripPlanner() {
|
|||||||
lastPrefetchKey.current = prefetchKey;
|
lastPrefetchKey.current = prefetchKey;
|
||||||
}, [prefetchKey, selectedVariant]);
|
}, [prefetchKey, selectedVariant]);
|
||||||
|
|
||||||
|
// Drop variant cache + prefetch ledger when travel dates change — pricing is stale.
|
||||||
|
const lastDatesKey = React.useRef<string>('');
|
||||||
|
React.useEffect(() => {
|
||||||
|
const key = `${travelDates.outbound || ''}|${travelDates.return || ''}|${travelDates.travellers || ''}`;
|
||||||
|
if (lastDatesKey.current && lastDatesKey.current !== key) {
|
||||||
|
setVariantCache(prev => {
|
||||||
|
const next: typeof prev = {};
|
||||||
|
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
prefetchedRef.current.clear();
|
||||||
|
}
|
||||||
|
lastDatesKey.current = key;
|
||||||
|
}, [travelDates.outbound, travelDates.return, travelDates.travellers, selectedVariant]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!prefetchKey) return;
|
if (!prefetchKey) return;
|
||||||
if (thinking || variantSwitching) return;
|
if (thinking || variantSwitching) return;
|
||||||
@@ -1506,6 +1721,8 @@ export default function TeslaTripPlanner() {
|
|||||||
selectedVariant: variantToUse,
|
selectedVariant: variantToUse,
|
||||||
origin: origin.trim() || undefined,
|
origin: origin.trim() || undefined,
|
||||||
destination: destination.trim() || undefined,
|
destination: destination.trim() || undefined,
|
||||||
|
travelDates: (travelDates.outbound || travelDates.return || travelDates.travellers)
|
||||||
|
? travelDates : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
|
if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
|
||||||
@@ -1650,6 +1867,16 @@ export default function TeslaTripPlanner() {
|
|||||||
toast.success(`Added ${place.name} to your trip`);
|
toast.success(`Added ${place.name} to your trip`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const moveStop = (stopId: string, direction: -1 | 1) => {
|
||||||
|
// Move within the flat list across day boundaries — nudges by one slot.
|
||||||
|
const flat = allStops;
|
||||||
|
const idx = flat.findIndex(s => s.id === stopId);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const targetIdx = idx + direction;
|
||||||
|
if (targetIdx < 0 || targetIdx >= flat.length) return;
|
||||||
|
reorderStops(stopId, flat[targetIdx].id);
|
||||||
|
};
|
||||||
|
|
||||||
const reorderStops = (dragId: string, targetId: string) => {
|
const reorderStops = (dragId: string, targetId: string) => {
|
||||||
if (dragId === targetId) return;
|
if (dragId === targetId) return;
|
||||||
const next = structuredClone(itinerary);
|
const next = structuredClone(itinerary);
|
||||||
@@ -1681,6 +1908,27 @@ export default function TeslaTripPlanner() {
|
|||||||
toast.info('Stop reordered');
|
toast.info('Stop reordered');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useMyLocation = async () => {
|
||||||
|
const t = toast.loading('Locating your car…');
|
||||||
|
const coords = await getBrowserLocation();
|
||||||
|
if (!coords) {
|
||||||
|
toast.error('Could not get your location', {
|
||||||
|
id: t,
|
||||||
|
description: "Tesla's browser may not expose GPS — type your postcode instead.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = await reverseGeocode(coords.latitude, coords.longitude);
|
||||||
|
if (!name) {
|
||||||
|
const fallback = `${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)}`;
|
||||||
|
setOrigin(fallback);
|
||||||
|
toast.success('Got your location', { id: t, description: fallback });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOrigin(name);
|
||||||
|
toast.success('Origin set to your location', { id: t, description: name });
|
||||||
|
};
|
||||||
|
|
||||||
const handleODCommit = () => {
|
const handleODCommit = () => {
|
||||||
const from = origin.trim();
|
const from = origin.trim();
|
||||||
const to = destination.trim();
|
const to = destination.trim();
|
||||||
@@ -1761,6 +2009,26 @@ export default function TeslaTripPlanner() {
|
|||||||
toast.success(`Swapped to ${alt.name}`);
|
toast.success(`Swapped to ${alt.name}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pickCrossing = (stopId: string, crossing: CrossingOption) => {
|
||||||
|
const next = structuredClone(itinerary);
|
||||||
|
for (const d of next.days) {
|
||||||
|
const idx = d.stops.findIndex(s => s.id === stopId);
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const stop = d.stops[idx];
|
||||||
|
if (!stop.crossingOptions) continue;
|
||||||
|
stop.crossingOptions = stop.crossingOptions.map(o => ({ ...o, isCurrent: o.id === crossing.id }));
|
||||||
|
stop.name = `${crossing.operator} · ${crossing.fromPort} → ${crossing.toPort}`;
|
||||||
|
stop.durationMin = crossing.durationMin;
|
||||||
|
stop.type = crossing.mode === 'tunnel' ? 'tunnel' : 'ferry';
|
||||||
|
}
|
||||||
|
setItinerary(next);
|
||||||
|
toast.success(`Switched crossing to ${crossing.operator}`, {
|
||||||
|
description: `${crossing.fromPort} → ${crossing.toPort} · ${formatDuration(crossing.durationMin)}`,
|
||||||
|
});
|
||||||
|
// Ask Grok to re-plan the road portions either side of the new crossing
|
||||||
|
sendMessage(`I picked the ${crossing.operator} crossing (${crossing.fromPort} → ${crossing.toPort}). Re-plan the stops on either side to match.`, { silent: true });
|
||||||
|
};
|
||||||
|
|
||||||
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
|
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
|
||||||
const dateLabels = ['Today', 'Tomorrow'];
|
const dateLabels = ['Today', 'Tomorrow'];
|
||||||
|
|
||||||
@@ -1778,6 +2046,9 @@ export default function TeslaTripPlanner() {
|
|||||||
onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }}
|
onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }}
|
||||||
grokStatus={grokStatus}
|
grokStatus={grokStatus}
|
||||||
onOpenGpx={() => setModal({ kind: 'gpx' })}
|
onOpenGpx={() => setModal({ kind: 'gpx' })}
|
||||||
|
travelDates={travelDates}
|
||||||
|
onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }}
|
||||||
|
onUseMyLocation={useMyLocation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{variants.length > 0 && (
|
{variants.length > 0 && (
|
||||||
@@ -2001,6 +2272,24 @@ export default function TeslaTripPlanner() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Travel-dates nudge */}
|
||||||
|
{itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { setDateAnchor(e.currentTarget.getBoundingClientRect()); setDatePickerOpen(true); }}
|
||||||
|
className="mx-4 mt-3 px-3 py-2 rounded-lg text-left flex items-center gap-2.5 transition hover:bg-white/[0.02]"
|
||||||
|
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--gd-red)' }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[11.5px] font-medium" style={{ color: 'var(--gd-red)' }}>Add travel dates for accurate pricing</div>
|
||||||
|
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
Ferry, Eurotunnel and hotel prices vary by date — currently showing off-peak ballpark.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="w-3 h-3 rotate-[-90deg]" style={{ color: 'var(--gd-red)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stops list */}
|
{/* Stops list */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
{itinerary.days.length === 0 ? (
|
{itinerary.days.length === 0 ? (
|
||||||
@@ -2060,6 +2349,11 @@ export default function TeslaTripPlanner() {
|
|||||||
onSwap={(alt) => swapStop(stop.id, alt)}
|
onSwap={(alt) => swapStop(stop.id, alt)}
|
||||||
onRemove={() => removeStop(stop.id)}
|
onRemove={() => removeStop(stop.id)}
|
||||||
onCustomise={() => setModal({ kind: 'customise', stopId: stop.id })}
|
onCustomise={() => setModal({ kind: 'customise', stopId: stop.id })}
|
||||||
|
onPickCrossing={(c) => pickCrossing(stop.id, c)}
|
||||||
|
onMoveUp={() => moveStop(stop.id, -1)}
|
||||||
|
onMoveDown={() => moveStop(stop.id, 1)}
|
||||||
|
canMoveUp={allStops[0]?.id !== stop.id}
|
||||||
|
canMoveDown={allStops[allStops.length - 1]?.id !== stop.id}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.setData('text/plain', stop.id);
|
e.dataTransfer.setData('text/plain', stop.id);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -2173,6 +2467,20 @@ export default function TeslaTripPlanner() {
|
|||||||
onSelect={setVehicle}
|
onSelect={setVehicle}
|
||||||
onClose={() => setVehiclePanelOpen(false)}
|
onClose={() => setVehiclePanelOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TravelDatesPanel
|
||||||
|
open={datePickerOpen}
|
||||||
|
anchorRect={dateAnchor}
|
||||||
|
value={travelDates}
|
||||||
|
onChange={setTravelDates}
|
||||||
|
onClose={() => setDatePickerOpen(false)}
|
||||||
|
onApply={() => {
|
||||||
|
setDatePickerOpen(false);
|
||||||
|
if (allStops.length > 0) {
|
||||||
|
sendMessage(`Update the itinerary for travel dates ${formatDateRange(travelDates.outbound, travelDates.return)}`, { silent: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2931,6 +3239,218 @@ function GpxExportModal({ itinerary, onClose }: { itinerary: Itinerary; onClose:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Travel dates panel ──────────────────────────────────────────────────────
|
||||||
|
function TravelDatesPanel({
|
||||||
|
open, anchorRect, value, onChange, onClose, onApply,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
anchorRect: DOMRect | null;
|
||||||
|
value: TravelDates;
|
||||||
|
onChange: (v: TravelDates) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: () => void;
|
||||||
|
}) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
if (!open || !anchorRect) return null;
|
||||||
|
const panelWidth = 380;
|
||||||
|
const left = Math.max(12, Math.min(window.innerWidth - panelWidth - 12, anchorRect.right - panelWidth));
|
||||||
|
const top = anchorRect.bottom + 8;
|
||||||
|
const nights = nightsBetween(value.outbound, value.return);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className="fixed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
zIndex: 9999,
|
||||||
|
left, top, width: panelWidth,
|
||||||
|
background: 'rgba(20,20,24,0.96)',
|
||||||
|
backdropFilter: 'blur(18px)',
|
||||||
|
border: '1px solid var(--gd-border-2)',
|
||||||
|
borderRadius: 14,
|
||||||
|
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-medium">Travel dates</div>
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
Sharpens crossing, ferry and hotel pricing
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nights != null && (
|
||||||
|
<div className="text-[11px] num px-2 py-0.5 rounded-full" style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}>
|
||||||
|
{nights} night{nights === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10.5px] uppercase tracking-wider mb-1.5" style={{ color: 'var(--gd-text-3)' }}>Outbound</div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
min={today}
|
||||||
|
value={value.outbound || ''}
|
||||||
|
onChange={(e) => onChange({ ...value, outbound: e.target.value || null })}
|
||||||
|
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
|
||||||
|
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
<span>Return</span>
|
||||||
|
{value.return && (
|
||||||
|
<button className="text-[10.5px] hover:underline" onClick={() => onChange({ ...value, return: null })}>
|
||||||
|
Make one-way
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
min={value.outbound || today}
|
||||||
|
value={value.return || ''}
|
||||||
|
onChange={(e) => onChange({ ...value, return: e.target.value || null })}
|
||||||
|
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
|
||||||
|
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
<span>Travellers</span>
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map(n => {
|
||||||
|
const selected = (value.travellers ?? 2) === n;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => onChange({ ...value, travellers: n })}
|
||||||
|
className="flex-1 h-9 rounded-lg text-[12.5px] num transition"
|
||||||
|
style={{
|
||||||
|
background: selected ? 'var(--gd-red)' : 'var(--gd-panel)',
|
||||||
|
border: `1px solid ${selected ? 'var(--gd-red)' : 'var(--gd-border)'}`,
|
||||||
|
color: selected ? '#fff' : 'var(--gd-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { onChange({ outbound: null, return: null, travellers: value.travellers }); }}
|
||||||
|
className="flex-1 h-9 text-[12px] rounded-lg"
|
||||||
|
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={!value.outbound}
|
||||||
|
className="flex-[2] h-9 text-[12px] rounded-lg disabled:opacity-40"
|
||||||
|
style={{ background: 'var(--gd-red)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{value.outbound ? 'Apply & re-quote' : 'Pick an outbound date'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Crossing swap block ─────────────────────────────────────────────────────
|
||||||
|
function CrossingSwapBlock({ options, onPick }: { options: CrossingOption[]; onPick: (c: CrossingOption) => void }) {
|
||||||
|
if (options.length === 0) return null;
|
||||||
|
const current = options.find(o => o.isCurrent) || options[0];
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-xl overflow-hidden" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
|
||||||
|
<div className="px-3 py-2 flex items-center gap-2" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||||
|
<Ship className="w-3.5 h-3.5" style={{ color: 'var(--gd-blue)' }} />
|
||||||
|
<div className="text-[10.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
Pick your crossing — {options.length} option{options.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y" style={{ borderColor: 'var(--gd-border)' }}>
|
||||||
|
{options.map(opt => {
|
||||||
|
const isPicked = opt.id === current.id;
|
||||||
|
const deltaMin = formatDelta(opt.detourMin, 'min');
|
||||||
|
const deltaKm = formatDelta(opt.detourKm, 'km');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => !isPicked && onPick(opt)}
|
||||||
|
disabled={isPicked}
|
||||||
|
className="w-full text-left px-3 py-2.5 transition hover:bg-white/[0.03] disabled:cursor-default"
|
||||||
|
style={{
|
||||||
|
background: isPicked ? 'var(--gd-blue-soft, rgba(80,140,255,0.08))' : 'transparent',
|
||||||
|
borderTop: 'none',
|
||||||
|
borderBottom: '1px solid var(--gd-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="text-[12.5px] font-medium flex-1" style={{ color: 'var(--gd-text)' }}>
|
||||||
|
{opt.operator}
|
||||||
|
</div>
|
||||||
|
{opt.badge && (
|
||||||
|
<span
|
||||||
|
className="text-[9.5px] font-semibold tracking-wide uppercase px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background: isPicked ? 'var(--gd-blue, #3b82f6)' : 'var(--gd-panel-2)',
|
||||||
|
color: isPicked ? '#fff' : 'var(--gd-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPicked && (
|
||||||
|
<span className="text-[9.5px] font-semibold uppercase px-1.5 py-0.5 rounded" style={{ background: 'var(--gd-blue, #3b82f6)', color: '#fff' }}>
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] mb-1.5" style={{ color: 'var(--gd-text-2)' }}>
|
||||||
|
{opt.fromPort} → {opt.toPort}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-[11px] num" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
<span><Clock className="w-3 h-3 inline mr-0.5" />{formatDuration(opt.durationMin)}</span>
|
||||||
|
<span><Euro className="w-3 h-3 inline mr-0.5" />€{Math.round(opt.priceEur)}</span>
|
||||||
|
{opt.frequency && <span className="truncate">· {opt.frequency}</span>}
|
||||||
|
</div>
|
||||||
|
{(deltaMin || deltaKm) && !isPicked && (
|
||||||
|
<div className="mt-1 text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
|
||||||
|
Trip impact: {deltaMin || '±0 min'} · {deltaKm || '±0 km'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{opt.bookingUrl && (
|
||||||
|
<a
|
||||||
|
href={opt.bookingUrl} target="_blank" rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-[10.5px] hover:underline"
|
||||||
|
style={{ color: 'var(--gd-blue, #5b8bff)' }}
|
||||||
|
>
|
||||||
|
Book <ExternalLink className="w-2.5 h-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Vehicle selector panel ──────────────────────────────────────────────────
|
// ─── Vehicle selector panel ──────────────────────────────────────────────────
|
||||||
function VehicleSelectorPanel({
|
function VehicleSelectorPanel({
|
||||||
open, anchorRect, selected, onSelect, onClose,
|
open, anchorRect, selected, onSelect, onClose,
|
||||||
|
|||||||
@@ -79,3 +79,72 @@ html, body, #root {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── In-car browser overrides ─────────────────────────────────────────────
|
||||||
|
* Triggered when <html class="incar"> is set by client/src/lib/incar.ts.
|
||||||
|
* Goals: bigger fonts, larger tap targets, no expensive blurs (MCU1/MCU2
|
||||||
|
* fall off a cliff with backdrop-filter), no accidental hover-only states.
|
||||||
|
* Scoped to .incar so it never affects desktop builds.
|
||||||
|
*/
|
||||||
|
html.incar {
|
||||||
|
font-size: 17px; /* baseline bump — most leaf text uses px values
|
||||||
|
so this primarily affects rem-based things */
|
||||||
|
}
|
||||||
|
html.incar body {
|
||||||
|
/* Slightly thicker base text colour for readability at arm's length */
|
||||||
|
color: var(--gd-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kill backdrop-filter entirely — it murders frame rate on MCU2 */
|
||||||
|
html.incar *,
|
||||||
|
html.incar *::before,
|
||||||
|
html.incar *::after {
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bump every interactive control to 44px minimum tap target. */
|
||||||
|
html.incar button,
|
||||||
|
html.incar [role="button"],
|
||||||
|
html.incar input[type="date"],
|
||||||
|
html.incar input[type="text"],
|
||||||
|
html.incar input[type="number"] {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale tiny utility text up — Tailwind ships these as fixed px values so
|
||||||
|
* we override them globally inside .incar. */
|
||||||
|
html.incar .text-\[10px\],
|
||||||
|
html.incar .text-\[10\.5px\],
|
||||||
|
html.incar .text-\[11px\] { font-size: 13px !important; }
|
||||||
|
html.incar .text-\[11\.5px\],
|
||||||
|
html.incar .text-\[12px\] { font-size: 14px !important; }
|
||||||
|
html.incar .text-\[12\.5px\],
|
||||||
|
html.incar .text-\[13px\],
|
||||||
|
html.incar .text-\[13\.5px\] { font-size: 15px !important; }
|
||||||
|
html.incar .text-\[14px\] { font-size: 16px !important; }
|
||||||
|
html.incar .text-\[15px\] { font-size: 17px !important; }
|
||||||
|
html.incar .text-\[16px\] { font-size: 18px !important; }
|
||||||
|
html.incar .text-\[18px\] { font-size: 20px !important; }
|
||||||
|
html.incar .text-\[20px\] { font-size: 22px !important; }
|
||||||
|
|
||||||
|
/* Native date input is a tiny target on touch — fatten it. */
|
||||||
|
html.incar input[type="date"] {
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bigger scrollbars — finger-friendly */
|
||||||
|
html.incar ::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover states cause flicker on touch — disable on the incar build. */
|
||||||
|
html.incar .hover\:bg-white\/\[0\.04\]:hover,
|
||||||
|
html.incar .hover\:bg-white\/\[0\.03\]:hover,
|
||||||
|
html.incar .hover\:bg-white\/\[0\.025\]:hover,
|
||||||
|
html.incar .hover\:bg-white\/\[0\.02\]:hover {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,4 +24,19 @@ export const env = {
|
|||||||
xaiApiKey: process.env.XAI_API_KEY || '',
|
xaiApiKey: process.env.XAI_API_KEY || '',
|
||||||
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
||||||
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
||||||
|
|
||||||
|
// Tesla Fleet API
|
||||||
|
tesla: {
|
||||||
|
// Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem
|
||||||
|
// for domain verification. Set TESLA_FLEET_PUBLIC_KEY to the PEM contents
|
||||||
|
// (multi-line; can include literal newlines).
|
||||||
|
publicKey: process.env.TESLA_FLEET_PUBLIC_KEY || '',
|
||||||
|
// OAuth client credentials Tesla gives you after partner approval.
|
||||||
|
clientId: process.env.TESLA_FLEET_CLIENT_ID || '',
|
||||||
|
clientSecret: process.env.TESLA_FLEET_CLIENT_SECRET || '',
|
||||||
|
// Where Tesla redirects after the user authorises.
|
||||||
|
redirectUri: process.env.TESLA_FLEET_REDIRECT_URI || 'https://roadtrip.tony.codes/api/auth/tesla/callback',
|
||||||
|
// Region: 'eu' or 'na'.
|
||||||
|
region: (process.env.TESLA_FLEET_REGION || 'eu') as 'eu' | 'na',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { logger } from './lib/logger.js';
|
import { logger } from './lib/logger.js';
|
||||||
import chatRoutes from './routes/chat.js';
|
import chatRoutes from './routes/chat.js';
|
||||||
import tripsRoutes from './routes/trips.js';
|
import tripsRoutes from './routes/trips.js';
|
||||||
|
import teslaRoutes from './routes/tesla.js';
|
||||||
import { createOptionalAuth } from './lib/auth.js';
|
import { createOptionalAuth } from './lib/auth.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -34,9 +38,31 @@ if (auth) {
|
|||||||
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tesla integration: serves the partner public key + OAuth callback. Mounted
|
||||||
|
// at the app root because Tesla's well-known path is fixed.
|
||||||
|
app.use(teslaRoutes);
|
||||||
|
|
||||||
app.use('/api', chatRoutes);
|
app.use('/api', chatRoutes);
|
||||||
app.use('/api/trips', tripsRoutes);
|
app.use('/api/trips', tripsRoutes);
|
||||||
|
|
||||||
|
// ─── Static client (production only) ─────────────────────────────────────────
|
||||||
|
// In dev, Vite serves the client on :5173. In production (Dokku), the built
|
||||||
|
// client lands in client/dist via `npm run build` and we serve it from here.
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const clientDist = path.resolve(__dirname, '../../client/dist');
|
||||||
|
if (existsSync(clientDist)) {
|
||||||
|
app.use(express.static(clientDist, { index: false, maxAge: '1h' }));
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
// Don't shadow API or well-known paths.
|
||||||
|
if (req.path.startsWith('/api') || req.path.startsWith('/.well-known')) return next();
|
||||||
|
res.sendFile(path.join(clientDist, 'index.html'));
|
||||||
|
});
|
||||||
|
logger.info({ clientDist }, 'Serving built client');
|
||||||
|
} else {
|
||||||
|
logger.info('No client/dist found — relying on Vite dev server');
|
||||||
|
}
|
||||||
|
|
||||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
logger.error({ err }, 'Unhandled error');
|
logger.error({ err }, 'Unhandled error');
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|||||||
+10
-4
@@ -15,6 +15,11 @@ const ChatRequestSchema = z.object({
|
|||||||
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
|
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
|
||||||
origin: z.string().optional(),
|
origin: z.string().optional(),
|
||||||
destination: z.string().optional(),
|
destination: z.string().optional(),
|
||||||
|
travelDates: z.object({
|
||||||
|
outbound: z.string().nullable().optional(),
|
||||||
|
return: z.string().nullable().optional(),
|
||||||
|
travellers: z.number().int().min(1).max(8).optional(),
|
||||||
|
}).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/chat', async (req, res) => {
|
router.post('/chat', async (req, res) => {
|
||||||
@@ -30,7 +35,7 @@ router.post('/chat', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid request' });
|
return res.status(400).json({ error: 'Invalid request' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
|
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
|
||||||
|
|
||||||
log.info({
|
log.info({
|
||||||
requestId,
|
requestId,
|
||||||
@@ -41,6 +46,7 @@ router.post('/chat', async (req, res) => {
|
|||||||
selectedVariant,
|
selectedVariant,
|
||||||
origin,
|
origin,
|
||||||
destination,
|
destination,
|
||||||
|
travelDates,
|
||||||
}, 'Parsed chat request');
|
}, 'Parsed chat request');
|
||||||
|
|
||||||
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
|
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
|
||||||
@@ -49,7 +55,7 @@ router.post('/chat', async (req, res) => {
|
|||||||
itinerary,
|
itinerary,
|
||||||
vehicle,
|
vehicle,
|
||||||
selectedVariant,
|
selectedVariant,
|
||||||
{ origin, destination },
|
{ origin, destination, travelDates },
|
||||||
);
|
);
|
||||||
|
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
@@ -91,7 +97,7 @@ router.post('/chat/stream', async (req, res) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
|
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
|
||||||
}
|
}
|
||||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
|
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||||
@@ -124,7 +130,7 @@ router.post('/chat/stream', async (req, res) => {
|
|||||||
itinerary,
|
itinerary,
|
||||||
vehicle,
|
vehicle,
|
||||||
selectedVariant,
|
selectedVariant,
|
||||||
{ origin, destination },
|
{ origin, destination, travelDates },
|
||||||
);
|
);
|
||||||
for await (const ev of stream) {
|
for await (const ev of stream) {
|
||||||
if (cancelled) break;
|
if (cancelled) break;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { createLogger } from '../lib/logger.js';
|
||||||
|
|
||||||
|
const log = createLogger('tesla');
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ─── Domain verification ────────────────────────────────────────────────────
|
||||||
|
// Tesla fetches this path to confirm you own the domain registered with the
|
||||||
|
// Fleet API partner account. The body must be the EXACT PEM the partner key
|
||||||
|
// is registered with (the EC public key from your prime256v1 keypair).
|
||||||
|
//
|
||||||
|
// Set TESLA_FLEET_PUBLIC_KEY in Dokku config to the full PEM contents —
|
||||||
|
// including the BEGIN/END lines. Multi-line env vars work fine with Dokku
|
||||||
|
// when set via `dokku config:set roadtrip TESLA_FLEET_PUBLIC_KEY="$(cat key.pem)"`.
|
||||||
|
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
|
||||||
|
if (!env.tesla.publicKey) {
|
||||||
|
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
|
||||||
|
res.status(404).type('text/plain').send('Public key not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.type('application/x-pem-file');
|
||||||
|
res.set('Cache-Control', 'public, max-age=300');
|
||||||
|
res.send(env.tesla.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── OAuth callback (stub) ──────────────────────────────────────────────────
|
||||||
|
// Tesla redirects here with ?code=… after the user grants access. We exchange
|
||||||
|
// the code for a refresh token and store it against the logged-in user.
|
||||||
|
// Full implementation lands once partner approval is granted.
|
||||||
|
router.get('/api/auth/tesla/callback', async (req, res) => {
|
||||||
|
const { code, state, error } = req.query as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.warn({ error, state }, 'Tesla OAuth error returned to callback');
|
||||||
|
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code) {
|
||||||
|
res.status(400).type('text/plain').send('Missing ?code from Tesla');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||||
|
log.warn('Tesla OAuth callback hit but client credentials not configured');
|
||||||
|
res.status(503).type('text/plain').send('Tesla integration not yet configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO once Tesla approves partner registration:
|
||||||
|
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code
|
||||||
|
// 2. Decode the id_token, persist refresh_token against req.auth.userId
|
||||||
|
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key
|
||||||
|
// 4. Redirect to / with a success flag
|
||||||
|
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)');
|
||||||
|
res.redirect('/?tesla_connected=pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Vehicle state (stub) ───────────────────────────────────────────────────
|
||||||
|
// Returns the current battery %, range and location for the connected vehicle.
|
||||||
|
// Until partner approval, returns 503 so the client can hide the integration UI.
|
||||||
|
router.get('/api/tesla/state', async (_req, res) => {
|
||||||
|
if (!env.tesla.clientId) {
|
||||||
|
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO: look up the user's stored refresh token, exchange for access token,
|
||||||
|
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data,
|
||||||
|
// and shape the response into { battery, range, lat, lng, state, etc. }.
|
||||||
|
res.status(501).json({ connected: false, reason: 'not_implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -160,7 +160,7 @@ export class GrokHeadlessClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}) {
|
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}) {
|
||||||
const variantBrief = {
|
const variantBrief = {
|
||||||
fast: 'Fastest — minimise drive time. Pick the most direct route via motorways. Sleep in the car or at a budget hotel with destination charging. Optimise for arriving sooner, not for sightseeing.',
|
fast: 'Fastest — minimise drive time. Pick the most direct route via motorways. Sleep in the car or at a budget hotel with destination charging. Optimise for arriving sooner, not for sightseeing.',
|
||||||
scenic: 'Scenic — pick the prettiest practical route even if it adds time. Favour scenic A-roads, viewpoints, charming towns, regional food. Stay at a hotel (not car-sleep). Add an extra hour or two for memorable stops.',
|
scenic: 'Scenic — pick the prettiest practical route even if it adds time. Favour scenic A-roads, viewpoints, charming towns, regional food. Stay at a hotel (not car-sleep). Add an extra hour or two for memorable stops.',
|
||||||
@@ -171,12 +171,18 @@ export class GrokHeadlessClient {
|
|||||||
? `\nTRIP ENDPOINTS (these are the ground truth — your itinerary MUST start exactly here and end exactly here):\n Origin: ${opts.origin}\n Destination: ${opts.destination}\n`
|
? `\nTRIP ENDPOINTS (these are the ground truth — your itinerary MUST start exactly here and end exactly here):\n Origin: ${opts.origin}\n Destination: ${opts.destination}\n`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const td = opts.travelDates;
|
||||||
|
const hasDates = !!(td && (td.outbound || td.return));
|
||||||
|
const datesBlock = hasDates
|
||||||
|
? `\nTRAVEL DATES (use these for crossing/ferry/hotel pricing — peak vs off-peak vs weekend):\n Outbound: ${td!.outbound || '(not set)'}\n Return: ${td!.return || '(one-way)'}\n Travellers: ${td!.travellers ?? 'unknown'}\n`
|
||||||
|
: `\nTRAVEL DATES: not yet provided by the user. Use ballpark off-peak prices for now and set "needsTravelDates": true on the itinerary so the UI prompts the user to add dates for accurate pricing.\n`;
|
||||||
|
|
||||||
return `You are Grok Drive, an expert Tesla road trip planner for the UK and Europe. You build practical, enjoyable itineraries — not just a list of charging stops. Treat every break as a chance to eat, rest, sightsee, or sleep.
|
return `You are Grok Drive, an expert Tesla road trip planner for the UK and Europe. You build practical, enjoyable itineraries — not just a list of charging stops. Treat every break as a chance to eat, rest, sightsee, or sleep.
|
||||||
|
|
||||||
Selected route variant: ${selectedVariant.toUpperCase()}
|
Selected route variant: ${selectedVariant.toUpperCase()}
|
||||||
${variantBrief}
|
${variantBrief}
|
||||||
|
|
||||||
Current vehicle: ${vehicleName(vehicle)}${odBlock}
|
Current vehicle: ${vehicleName(vehicle)}${odBlock}${datesBlock}
|
||||||
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
|
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
|
||||||
|
|
||||||
Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown.
|
Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown.
|
||||||
@@ -245,6 +251,25 @@ Respond with **only** a single valid JSON object in exactly this format. No text
|
|||||||
"isCurrent": true,
|
"isCurrent": true,
|
||||||
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null
|
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"crossingOptions": [
|
||||||
|
{
|
||||||
|
"id": "unique-crossing-id",
|
||||||
|
"operator": "Eurotunnel Le Shuttle" | "DFDS" | "P&O Ferries" | "Brittany Ferries" | "Stena Line" | "Irish Ferries" | "Other",
|
||||||
|
"mode": "tunnel" | "ferry",
|
||||||
|
"fromPort": "Folkestone, UK",
|
||||||
|
"toPort": "Coquelles (Calais), FR",
|
||||||
|
"durationMin": 35,
|
||||||
|
"priceEur": 180,
|
||||||
|
"frequency": "every 30 min, 24/7",
|
||||||
|
"pros": ["Fastest", "Drive on/off, no walking"],
|
||||||
|
"cons": ["Most expensive"],
|
||||||
|
"badge": "Fastest" | "Cheapest" | "Most scenic" | "Overnight" | "Frequent" | null,
|
||||||
|
"detourMin": 0,
|
||||||
|
"detourKm": 0,
|
||||||
|
"isCurrent": true,
|
||||||
|
"bookingUrl": "https://www.eurotunnel.com"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -257,7 +282,8 @@ Respond with **only** a single valid JSON object in exactly this format. No text
|
|||||||
"superchargers": 3,
|
"superchargers": 3,
|
||||||
"hotels": 1,
|
"hotels": 1,
|
||||||
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
|
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
|
||||||
}
|
},
|
||||||
|
"needsTravelDates": true
|
||||||
},
|
},
|
||||||
"variants": [
|
"variants": [
|
||||||
{
|
{
|
||||||
@@ -310,6 +336,11 @@ Strict route planning rules:
|
|||||||
- "message" should feel like a helpful human assistant.
|
- "message" should feel like a helpful human assistant.
|
||||||
- If no clear trip is requested yet, set "itinerary" to null.
|
- If no clear trip is requested yet, set "itinerary" to null.
|
||||||
|
|
||||||
|
Travel dates & pricing:
|
||||||
|
- If the user has NOT provided travel dates, set "needsTravelDates": true on the itinerary object. In "message", briefly mention that adding dates will sharpen the crossing/ferry and hotel pricing. Use moderate off-peak prices in the meantime.
|
||||||
|
- If dates ARE provided, set "needsTravelDates": false and lean prices to the right tier (weekend, school holidays, peak summer, etc.) and mention that in "message" where relevant.
|
||||||
|
- Hotel prices in "description" can include a rough nightly rate when known ("Premier Inn from £75/night on those dates"). Don't fabricate exact prices for specific rooms.
|
||||||
|
|
||||||
Route variants (REQUIRED):
|
Route variants (REQUIRED):
|
||||||
- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order.
|
- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order.
|
||||||
- Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}".
|
- Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}".
|
||||||
@@ -333,6 +364,20 @@ Charger options (REQUIRED for every Supercharger and destination-charger stop):
|
|||||||
- "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick.
|
- "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick.
|
||||||
- This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc.
|
- This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc.
|
||||||
|
|
||||||
|
Sea crossings (REQUIRED for every UK ↔ mainland Europe trip, and any other route that crosses water):
|
||||||
|
- When the route includes a Channel crossing or any other sea/tunnel crossing, insert a dedicated stop with type "crossing" (use "tunnel" for Eurotunnel, "ferry" for ferries) at the appropriate point in the day's stops.
|
||||||
|
- That crossing stop MUST populate "crossingOptions" with 3-5 genuinely different real-world options the user could pick from. The currently-chosen one is duplicated as the first entry with isCurrent: true.
|
||||||
|
- For UK ↔ France: at minimum include Eurotunnel Le Shuttle (Folkestone→Coquelles), DFDS Dover→Calais, P&O Dover→Calais (or Dover→Dunkirk), and at least one longer/scenic option (DFDS Newhaven→Dieppe, Brittany Ferries Portsmouth→Caen/Le Havre/Cherbourg, or Brittany Ferries Plymouth→Roscoff) when they make geographic sense for the route.
|
||||||
|
- For UK ↔ Netherlands/Belgium: include P&O Hull→Rotterdam, Stena Line Harwich→Hook of Holland, DFDS Newcastle→Amsterdam where appropriate.
|
||||||
|
- For UK ↔ Ireland: include Irish Ferries / Stena Line Holyhead→Dublin, Liverpool→Belfast/Dublin, Fishguard→Rosslare etc.
|
||||||
|
- "priceEur" should reflect a realistic ballpark for a Tesla with the given travellers. If travel dates are provided, lean toward the right pricing tier (weekday off-peak vs weekend peak vs school holidays). If no dates yet, use moderate off-peak pricing.
|
||||||
|
- "durationMin" is the crossing time itself (35 min for Eurotunnel, ~90 min for Dover-Calais ferry, ~14 h for an overnight Hull-Rotterdam, etc.).
|
||||||
|
- "frequency" is a one-line text description of departure cadence (e.g. "every 30 min, 24/7", "8 sailings/day", "1 overnight sailing/day").
|
||||||
|
- "badge" picks the trade-off: "Fastest" for Eurotunnel, "Cheapest" for the cheapest sensible ferry, "Most scenic" for routes that swap a few hours of UK driving for a longer but prettier crossing (e.g. Portsmouth→Caen), "Overnight" for sleeper ferries (frees up a hotel night), "Frequent" for high-cadence operators.
|
||||||
|
- "detourMin" and "detourKm" express the change in TOTAL trip distance + drive time vs the current chosen crossing (positive = adds, negative = saves). E.g. Portsmouth→Caen vs Dover→Calais might save 200 km of French driving but add 150 km of UK driving and a 6 h crossing.
|
||||||
|
- Always set a sensible "bookingUrl" (operator's main booking page).
|
||||||
|
- If the user already picked a crossing, keep its choice as isCurrent: true and adjust the rest of the itinerary's routing accordingly.
|
||||||
|
|
||||||
Alternatives (REQUIRED for every Supercharger and hotel stop):
|
Alternatives (REQUIRED for every Supercharger and hotel stop):
|
||||||
- For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer.
|
- For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer.
|
||||||
- Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description.
|
- Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description.
|
||||||
@@ -363,7 +408,7 @@ ${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
|
|||||||
Respond with ONLY the JSON object.`;
|
Respond with ONLY the JSON object.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
|
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
|
||||||
const requestId = crypto.randomUUID().slice(0, 8);
|
const requestId = crypto.randomUUID().slice(0, 8);
|
||||||
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ===');
|
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ===');
|
||||||
|
|
||||||
@@ -440,7 +485,7 @@ Respond with ONLY the JSON object.`;
|
|||||||
* Streaming chat — yields incremental partial itineraries as Grok produces output.
|
* Streaming chat — yields incremental partial itineraries as Grok produces output.
|
||||||
* Falls back to non-streaming if local CLI is unavailable.
|
* Falls back to non-streaming if local CLI is unavailable.
|
||||||
*/
|
*/
|
||||||
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): AsyncGenerator<StreamEvent> {
|
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): AsyncGenerator<StreamEvent> {
|
||||||
const requestId = crypto.randomUUID().slice(0, 8);
|
const requestId = crypto.randomUUID().slice(0, 8);
|
||||||
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
|
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
|
||||||
|
|
||||||
@@ -629,7 +674,7 @@ Respond with ONLY the JSON object.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
|
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
|
||||||
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
|
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
|
||||||
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
|
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user