feat: Phase 2 — variant strip, while-here, charger swap block
Adds the three big "options" UX wins from Direction B:
1. Route variants (Fastest / Scenic / Cheapest)
- Grok prompt now returns a top-level variants[] summary with
drive/charge/cost/distance/pros for each variant, plus a
selectedVariant indicating which one the stops reflect.
- VariantStrip renders under the top bar with selected-state
styling, tone-coloured highlight (red/green/blue) on the most
relevant stat, and 3-5 pros pills.
- Clicking a variant fires /api/chat with selectedVariant=<id> so
Grok re-plans with that variant's bias. A "switching" state
disables the strip while the request is in flight.
- The chat route accepts selectedVariant ('fast'|'scenic'|'cheap')
and the GrokHeadlessClient threads it through both the local CLI
and xAI API paths.
2. While here (food / do / see / shop / rest)
- Every Supercharger, destination-charger and hotel stop now
returns a nearby[] array with category/icon/name/detail.
- Expanded stop card has tabs (All / Food / Do / See) — tabs
auto-hide when no items in that category. Two-column grid of
named places with walk-time + rating, e.g. "M&S Foodhall · 1 min
walk · 4.3★ · sandwiches".
3. Charger swap block
- Every charging stop now returns chargerOptions[] with the
current pick + 1-3 alternatives at the same location, each with
network (Tesla/Ionity/Allego/Fastned/BP Pulse), stalls, kW,
pricePerKwh, detourMin and an optional badge (Faster/Cheaper/
More stalls/Newer).
- ChargerSwapBlock shows the current charger as a red-tinted
header row that expands to reveal alternatives with stats and a
Use button per row.
Renamed the existing AlternativeStop UI label from "alternative(s)"
to "location alternative(s)" so it's clear when the user is swapping
the stop *location* vs swapping the *charger at the same location*.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X,
|
||||
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
|
||||
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
|
||||
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock,
|
||||
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Fix Leaflet default icons (we still need pins for non-active stops)
|
||||
@@ -20,6 +20,37 @@ L.Icon.Default.mergeOptions({
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel';
|
||||
|
||||
interface NearbyPlace {
|
||||
category: 'food' | 'do' | 'see' | 'shop' | 'rest';
|
||||
icon: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface ChargerOption {
|
||||
id: string;
|
||||
name: string;
|
||||
network?: string;
|
||||
stalls: number;
|
||||
kw: number;
|
||||
pricePerKwh: number;
|
||||
detourMin: number;
|
||||
isCurrent?: boolean;
|
||||
badge?: string | null;
|
||||
}
|
||||
|
||||
interface RouteVariant {
|
||||
id: 'fast' | 'scenic' | 'cheap';
|
||||
label: string;
|
||||
tone: 'primary' | 'green' | 'blue';
|
||||
distanceKm: number;
|
||||
driveHours: number;
|
||||
chargeHours: number;
|
||||
costEur: number;
|
||||
highlight?: 'drive' | 'cost' | 'pretty';
|
||||
pros: string[];
|
||||
}
|
||||
|
||||
interface AlternativeStop {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -56,6 +87,8 @@ interface Stop {
|
||||
priceLevel?: number;
|
||||
notes?: string;
|
||||
alternatives?: AlternativeStop[];
|
||||
nearby?: NearbyPlace[];
|
||||
chargerOptions?: ChargerOption[];
|
||||
}
|
||||
|
||||
interface Itinerary {
|
||||
@@ -196,6 +229,31 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
||||
})
|
||||
.filter((a: AlternativeStop | null): a is AlternativeStop => a !== null);
|
||||
|
||||
const cleanNearby: NearbyPlace[] = Array.isArray(s.nearby)
|
||||
? s.nearby.filter((n: any) => n && typeof n.name === 'string').map((n: any) => ({
|
||||
category: ['food', 'do', 'see', 'shop', 'rest'].includes(n.category) ? n.category : 'food',
|
||||
icon: typeof n.icon === 'string' ? n.icon : 'coffee',
|
||||
name: n.name,
|
||||
detail: typeof n.detail === 'string' ? n.detail : '',
|
||||
}))
|
||||
: [];
|
||||
|
||||
const cleanChargers: ChargerOption[] = Array.isArray(s.chargerOptions)
|
||||
? s.chargerOptions
|
||||
.filter((c: any) => c && typeof c.name === 'string')
|
||||
.map((c: any): ChargerOption => ({
|
||||
id: c.id || `charger-${Date.now()}-${Math.random()}`,
|
||||
name: c.name,
|
||||
network: typeof c.network === 'string' ? c.network : undefined,
|
||||
stalls: typeof c.stalls === 'number' ? c.stalls : 0,
|
||||
kw: typeof c.kw === 'number' ? c.kw : 0,
|
||||
pricePerKwh: typeof c.pricePerKwh === 'number' ? c.pricePerKwh : 0,
|
||||
detourMin: typeof c.detourMin === 'number' ? c.detourMin : 0,
|
||||
isCurrent: c.isCurrent === true,
|
||||
badge: typeof c.badge === 'string' ? c.badge : null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const shared = {
|
||||
estArrivalBattery: s.estArrivalBattery,
|
||||
chargeMinutes: s.chargeMinutes,
|
||||
@@ -207,6 +265,8 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
||||
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
|
||||
notes: s.notes,
|
||||
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
|
||||
nearby: cleanNearby.length > 0 ? cleanNearby : undefined,
|
||||
chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined,
|
||||
};
|
||||
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
|
||||
|
||||
@@ -247,6 +307,23 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVariants(raw: any): RouteVariant[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((v: any) => v && typeof v.id === 'string')
|
||||
.map((v: any): RouteVariant => ({
|
||||
id: v.id === 'scenic' || v.id === 'cheap' ? v.id : 'fast',
|
||||
label: typeof v.label === 'string' ? v.label : v.id,
|
||||
tone: v.tone === 'green' || v.tone === 'blue' ? v.tone : 'primary',
|
||||
distanceKm: typeof v.distanceKm === 'number' ? v.distanceKm : 0,
|
||||
driveHours: typeof v.driveHours === 'number' ? v.driveHours : 0,
|
||||
chargeHours: typeof v.chargeHours === 'number' ? v.chargeHours : 0,
|
||||
costEur: typeof v.costEur === 'number' ? v.costEur : 0,
|
||||
highlight: ['drive', 'cost', 'pretty'].includes(v.highlight) ? v.highlight : undefined,
|
||||
pros: Array.isArray(v.pros) ? v.pros.filter((p: unknown) => typeof p === 'string') : [],
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Routing helpers ─────────────────────────────────────────────────────────
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371;
|
||||
@@ -353,6 +430,103 @@ function LegRow({ leg }: { leg: Leg | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Variant strip ───────────────────────────────────────────────────────────
|
||||
const VARIANT_TONE: Record<RouteVariant['tone'], string> = {
|
||||
primary: 'var(--gd-red)',
|
||||
green: 'var(--gd-green)',
|
||||
blue: 'var(--gd-blue)',
|
||||
};
|
||||
|
||||
const VARIANT_ICON: Record<RouteVariant['id'], React.ComponentType<{ className?: string; size?: number }>> = {
|
||||
fast: Gauge,
|
||||
scenic: TreePine,
|
||||
cheap: Euro,
|
||||
};
|
||||
|
||||
function VariantStrip({
|
||||
variants, selected, onSelect, switching,
|
||||
}: {
|
||||
variants: RouteVariant[];
|
||||
selected: string;
|
||||
onSelect: (id: RouteVariant['id']) => void;
|
||||
switching: boolean;
|
||||
}) {
|
||||
if (variants.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="px-6 py-3.5 grid grid-cols-3 gap-3 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--gd-border)', background: 'var(--gd-bg)' }}
|
||||
>
|
||||
{variants.map(v => {
|
||||
const isSel = v.id === selected;
|
||||
const tone = VARIANT_TONE[v.tone];
|
||||
const Icon = VARIANT_ICON[v.id];
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
disabled={switching}
|
||||
className="relative text-left p-3.5 rounded-[14px] overflow-hidden transition disabled:opacity-50 disabled:cursor-wait"
|
||||
style={{
|
||||
background: isSel ? `color-mix(in srgb, ${tone} 6%, transparent)` : 'var(--gd-panel)',
|
||||
border: `1px solid ${isSel ? tone : 'var(--gd-border)'}`,
|
||||
cursor: switching ? 'wait' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isSel && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: tone }} />
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="text-[14px] font-semibold" style={{ letterSpacing: '-0.01em' }}>{v.label}</div>
|
||||
{isSel && (
|
||||
<span
|
||||
className="text-[9px] font-semibold tracking-wider px-1.5 py-0.5 rounded-full"
|
||||
style={{ border: `1px solid ${tone}`, color: tone }}
|
||||
>
|
||||
SELECTED
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Icon size={16} style={{ color: tone }} />
|
||||
</div>
|
||||
<div className="flex gap-4 mb-3">
|
||||
<VStat label="Drive" value={formatDuration(v.driveHours * 60)} highlight={v.highlight === 'drive'} tone={tone} />
|
||||
<VStat label="Charge" value={formatDuration(v.chargeHours * 60)} />
|
||||
<VStat label="Cost" value={`€${Math.round(v.costEur)}`} highlight={v.highlight === 'cost'} tone={tone} />
|
||||
<VStat label="Distance" value={formatKm(v.distanceKm)} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{v.pros.map(p => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10.5px] px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--gd-text-2)' }}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VStat({ label, value, highlight, tone }: { label: string; value: string; highlight?: boolean; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>{label}</div>
|
||||
<div
|
||||
className="text-[15px] font-medium num mt-px whitespace-nowrap"
|
||||
style={{ color: highlight ? tone : 'var(--gd-text)' }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Day header (sticky) ─────────────────────────────────────────────────────
|
||||
function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: {
|
||||
dayNumber: number;
|
||||
@@ -418,7 +592,116 @@ function AlternativeRow({ alt, onSwap }: { alt: AlternativeStop; onSwap: () => v
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Expanded stop body (charger swap, stats, alts, actions) ─────────────────
|
||||
// ─── Expanded stop body (stats, charger swap, while-here, alts, actions) ─────
|
||||
const NEARBY_TABS: { id: 'all' | NearbyPlace['category']; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'food', label: 'Food' },
|
||||
{ id: 'do', label: 'Do' },
|
||||
{ id: 'see', label: 'See' },
|
||||
];
|
||||
|
||||
function NearbyGrid({ items }: { items: NearbyPlace[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{items.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-2 rounded-lg flex items-center gap-2"
|
||||
style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded grid place-items-center flex-shrink-0 text-[13px] leading-none"
|
||||
style={{ background: 'var(--gd-panel-2)' }}
|
||||
>
|
||||
{AMENITY_ICONS[n.icon] || '•'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11.5px] font-medium truncate">{n.name}</div>
|
||||
<div className="text-[10px] truncate" style={{ color: 'var(--gd-text-3)' }}>{n.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPick: (c: ChargerOption) => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
if (options.length === 0) return null;
|
||||
const current = options.find(o => o.isCurrent) || options[0];
|
||||
const others = options.filter(o => o.id !== current.id);
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
|
||||
className="w-full p-2.5 rounded-lg flex items-center gap-2.5 text-left transition"
|
||||
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md grid place-items-center flex-shrink-0"
|
||||
style={{ background: 'rgba(74,222,128,0.18)' }}
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--gd-green)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-medium truncate">{current.name}</div>
|
||||
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
|
||||
{current.stalls} stalls · {current.kw} kW · €{current.pricePerKwh.toFixed(2)}/kWh
|
||||
{current.network && current.network !== 'Tesla' ? ` · ${current.network}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] font-medium flex items-center gap-1 flex-shrink-0" style={{ color: 'var(--gd-red)' }}>
|
||||
{others.length > 0 && `${others.length} alts`}
|
||||
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</div>
|
||||
</button>
|
||||
{open && others.length > 0 && (
|
||||
<div className="mt-1.5 p-1.5 rounded-lg space-y-0.5" style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}>
|
||||
{others.map(o => (
|
||||
<div
|
||||
key={o.id}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2.5 rounded-md flex items-center gap-2.5 transition hover:bg-white/[0.04]"
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded grid place-items-center flex-shrink-0"
|
||||
style={{ background: 'rgba(255,255,255,0.04)' }}
|
||||
>
|
||||
<Zap className="w-3 h-3" style={{ color: 'var(--gd-text-2)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-medium flex items-center gap-1.5">
|
||||
<span className="truncate">{o.name}</span>
|
||||
{o.badge && (
|
||||
<span
|
||||
className="text-[9px] px-1.5 py-px rounded font-semibold whitespace-nowrap"
|
||||
style={{ color: 'var(--gd-amber)', border: '1px solid rgba(251,191,36,0.4)' }}
|
||||
>
|
||||
{o.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
|
||||
{o.stalls} stalls · {o.kw} kW · €{o.pricePerKwh.toFixed(2)}/kWh
|
||||
{o.detourMin > 0 ? ` · +${o.detourMin} min` : ''}
|
||||
{o.network && o.network !== 'Tesla' ? ` · ${o.network}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPick(o); }}
|
||||
className="h-6 px-2.5 rounded-md text-[10.5px] flex-shrink-0"
|
||||
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopExpansion({ stop, onSwap, onRemove }: {
|
||||
stop: Stop;
|
||||
onSwap: (alt: AlternativeStop) => void;
|
||||
@@ -431,6 +714,11 @@ function StopExpansion({ stop, onSwap, onRemove }: {
|
||||
const cost = arrive != null && charge != null ? (Math.max(20, 80 - arrive) * 0.35).toFixed(2) : null;
|
||||
const amenities = (stop.amenities || []).slice(0, 8);
|
||||
const alts = stop.alternatives || [];
|
||||
const chargers = stop.chargerOptions || [];
|
||||
const nearby = stop.nearby || [];
|
||||
|
||||
const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all');
|
||||
const filteredNearby = nearbyTab === 'all' ? nearby : nearby.filter(n => n.category === nearbyTab);
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
|
||||
@@ -447,6 +735,52 @@ function StopExpansion({ stop, onSwap, onRemove }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCharge && chargers.length > 0 && (
|
||||
<div className="mb-3.5">
|
||||
<SectionLabel>Charger</SectionLabel>
|
||||
<ChargerSwapBlock
|
||||
options={chargers}
|
||||
onPick={(c) => toast.success(`Picked ${c.name}`, { description: `${c.kw} kW · €${c.pricePerKwh.toFixed(2)}/kWh` })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nearby.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<SectionLabel>While here</SectionLabel>
|
||||
<div className="flex gap-1">
|
||||
{NEARBY_TABS.map(t => {
|
||||
const isSel = nearbyTab === t.id;
|
||||
const exists = t.id === 'all' || nearby.some(n => n.category === t.id);
|
||||
if (!exists) return null;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={(e) => { e.stopPropagation(); setNearbyTab(t.id); }}
|
||||
className="h-5.5 px-2 rounded-md text-[10.5px] transition"
|
||||
style={{
|
||||
background: isSel ? 'var(--gd-red-soft)' : 'transparent',
|
||||
color: isSel ? 'var(--gd-red)' : 'var(--gd-text-3)',
|
||||
fontWeight: isSel ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{filteredNearby.length > 0 ? (
|
||||
<NearbyGrid items={filteredNearby.slice(0, 6)} />
|
||||
) : (
|
||||
<div className="text-[11px] italic px-2 py-2" style={{ color: 'var(--gd-text-3)' }}>
|
||||
Nothing in this category nearby.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{amenities.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<SectionLabel>Amenities</SectionLabel>
|
||||
@@ -467,7 +801,7 @@ function StopExpansion({ stop, onSwap, onRemove }: {
|
||||
|
||||
{alts.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<SectionLabel>{alts.length} alternative{alts.length === 1 ? '' : 's'}</SectionLabel>
|
||||
<SectionLabel>{alts.length} location alternative{alts.length === 1 ? '' : 's'}</SectionLabel>
|
||||
<div className="space-y-1.5">
|
||||
{alts.map(alt => (
|
||||
<AlternativeRow key={alt.id} alt={alt} onSwap={() => onSwap(alt)} />
|
||||
@@ -805,6 +1139,9 @@ export default function TeslaTripPlanner() {
|
||||
const [hoverStopId, setHoverStopId] = useState<string | null>(null);
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
||||
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
||||
const [variantSwitching, setVariantSwitching] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {});
|
||||
@@ -850,13 +1187,17 @@ export default function TeslaTripPlanner() {
|
||||
return { totalKm: km, driveMinutes: min };
|
||||
}, [legs]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean } = {}) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
|
||||
setChatInput('');
|
||||
setChips(prev => [...prev, trimmed].slice(-6));
|
||||
setThinking(true);
|
||||
const variantToUse = opts.variant ?? selectedVariant;
|
||||
if (!opts.silent) {
|
||||
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
|
||||
setChatInput('');
|
||||
setChips(prev => [...prev, trimmed].slice(-6));
|
||||
}
|
||||
if (opts.variant) setVariantSwitching(true);
|
||||
else setThinking(true);
|
||||
try {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -866,26 +1207,52 @@ export default function TeslaTripPlanner() {
|
||||
vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm },
|
||||
itinerary,
|
||||
history: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
selectedVariant: variantToUse,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to get response from server');
|
||||
const data = await response.json();
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]);
|
||||
if (!opts.silent) {
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]);
|
||||
}
|
||||
if (data.itinerary) {
|
||||
const clean = await normalizeAndSanitizeItinerary(data.itinerary);
|
||||
setItinerary(clean);
|
||||
toast.success('Grok updated your route', {
|
||||
description: `${clean.days.length} day(s) · ${clean.summary.superchargers} chargers · ${clean.summary.hotels} overnight`,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(data.variants)) {
|
||||
setVariants(normalizeVariants(data.variants));
|
||||
}
|
||||
if (typeof data.selectedVariant === 'string') {
|
||||
setSelectedVariant(data.selectedVariant as RouteVariant['id']);
|
||||
} else if (opts.variant) {
|
||||
setSelectedVariant(opts.variant);
|
||||
}
|
||||
if (data.itinerary && !opts.silent) {
|
||||
toast.success('Grok updated your route');
|
||||
} else if (opts.variant) {
|
||||
toast.success(`Switched to ${opts.variant} route`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[TeslaTrip] Grok call failed:', err);
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
|
||||
if (!opts.silent) {
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
|
||||
}
|
||||
} finally {
|
||||
setThinking(false);
|
||||
setVariantSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchVariant = (variantId: RouteVariant['id']) => {
|
||||
if (variantId === selectedVariant || variantSwitching || thinking) return;
|
||||
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
||||
if (!lastUserMsg) {
|
||||
toast.info('Send a trip prompt first');
|
||||
return;
|
||||
}
|
||||
sendMessage(`Replan the trip as the ${variantId} variant`, { variant: variantId, silent: true });
|
||||
};
|
||||
|
||||
const removeStop = (stopId: string) => {
|
||||
const next = structuredClone(itinerary);
|
||||
next.days.forEach(d => { d.stops = d.stops.filter(s => s.id !== stopId); });
|
||||
@@ -944,6 +1311,15 @@ export default function TeslaTripPlanner() {
|
||||
grokStatus={grokStatus}
|
||||
/>
|
||||
|
||||
{variants.length > 0 && (
|
||||
<VariantStrip
|
||||
variants={variants}
|
||||
selected={selectedVariant}
|
||||
onSelect={switchVariant}
|
||||
switching={variantSwitching}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Body: map left, rail right */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Map */}
|
||||
|
||||
Reference in New Issue
Block a user