diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index 16a1775..a12c1b5 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -857,15 +857,59 @@ function ExpStat({ label, value, tone = 'text' }: { label: string; value: string ); } +// ─── Night transition block (between days) ─────────────────────────────────── +function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHotelOptions: () => void }) { + const isHotel = lastStop.type === 'hotel'; + const label = isHotel ? lastStop.name : 'Sleep in car at services'; + const detail = isHotel + ? `Overnight · ${lastStop.cuisine || 'destination charging'}` + : 'Safe overnight at last Supercharger · 24h facilities'; + return ( +
+
+ +
+
+
{label}
+
{detail}
+
+ +
+ ); +} + // ─── Stop card (icon-led) ──────────────────────────────────────────────────── -function StopCard({ stop, active, hover, onSelect, onHover, onSwap, onRemove }: { +function StopCard({ + stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, + onDragStart, onDragOver, onDrop, onDragEnd, +}: { stop: Stop; active: boolean; hover: boolean; + dragging: boolean; onSelect: () => void; onHover: (h: boolean) => void; onSwap: (alt: AlternativeStop) => void; onRemove: () => void; + onDragStart: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + onDragEnd: () => void; }) { const meta = stopMeta(stop.type); const Icon = meta.icon; @@ -878,11 +922,17 @@ function StopCard({ stop, active, hover, onSelect, onHover, onSwap, onRemove }: onClick={onSelect} onMouseEnter={() => onHover(true)} onMouseLeave={() => onHover(false)} + draggable + onDragStart={onDragStart} + onDragOver={onDragOver} + onDrop={onDrop} + onDragEnd={onDragEnd} className="rounded-[12px] p-3.5 mb-2 cursor-pointer transition-all" style={{ background: active ? 'var(--gd-panel-2)' : hover ? 'rgba(255,255,255,0.025)' : 'var(--gd-panel)', border: `1px solid ${active ? 'var(--gd-red-line)' : hover ? 'var(--gd-border-2)' : 'var(--gd-border)'}`, boxShadow: active ? '0 0 0 1px var(--gd-red-soft), 0 6px 20px rgba(0,0,0,0.25)' : 'none', + opacity: dragging ? 0.5 : 1, }} >
@@ -977,13 +1027,14 @@ function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon // ─── Top bar ───────────────────────────────────────────────────────────────── function TopBar({ - origin, destination, onOriginChange, onDestinationChange, + origin, destination, onOriginChange, onDestinationChange, onODCommit, chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, vehicle, onVehicleChange, grokStatus, }: { origin: string; destination: string; onOriginChange: (v: string) => void; onDestinationChange: (v: string) => void; + onODCommit: () => void; chatInput: string; setChatInput: (v: string) => void; onChatSubmit: () => void; chips: string[]; onRemoveChip: (i: number) => void; @@ -1021,6 +1072,8 @@ function TopBar({ onOriginChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }} + onBlur={onODCommit} placeholder="From" className="bg-transparent border-none outline-none text-[13px] w-full" style={{ color: 'var(--gd-text)' }} @@ -1031,6 +1084,8 @@ function TopBar({ onDestinationChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }} + onBlur={onODCommit} placeholder="To" className="bg-transparent border-none outline-none text-[13px] w-full" style={{ color: 'var(--gd-text)' }} @@ -1139,9 +1194,11 @@ export default function TeslaTripPlanner() { const [hoverStopId, setHoverStopId] = useState(null); const [origin, setOrigin] = useState(''); const [destination, setDestination] = useState(''); + const lastODSent = React.useRef<{ from: string; to: string } | null>(null); const [variants, setVariants] = useState([]); const [selectedVariant, setSelectedVariant] = useState('fast'); const [variantSwitching, setVariantSwitching] = useState(false); + const [draggingId, setDraggingId] = useState(null); React.useEffect(() => { fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {}); @@ -1243,6 +1300,52 @@ export default function TeslaTripPlanner() { } }; + const reorderStops = (dragId: string, targetId: string) => { + if (dragId === targetId) return; + const next = structuredClone(itinerary); + // Find the dragged stop across all days and remove it + let dragged: Stop | null = null; + for (const d of next.days) { + const idx = d.stops.findIndex(s => s.id === dragId); + if (idx !== -1) { + dragged = d.stops.splice(idx, 1)[0] as Stop; + break; + } + } + if (!dragged) return; + // Insert before the target stop + for (const d of next.days) { + const idx = d.stops.findIndex(s => s.id === targetId); + if (idx !== -1) { + dragged.day = d.day; + d.stops.splice(idx, 0, dragged); + break; + } + } + // Re-normalise order numbers within each day + for (const d of next.days) { + d.stops.forEach((s, i) => { s.order = i + 1; }); + } + next.days = next.days.filter(d => d.stops.length > 0); + setItinerary(next); + toast.info('Stop reordered'); + }; + + const handleODCommit = () => { + const from = origin.trim(); + const to = destination.trim(); + if (!from || !to) return; + if (lastODSent.current && lastODSent.current.from === from && lastODSent.current.to === to) return; + // First time: only fire if user has actually edited (we auto-populate from itinerary) + const auto = allStops.length > 0 && from === allStops[0].name && to === allStops[allStops.length - 1].name; + if (auto && !lastODSent.current) { + lastODSent.current = { from, to }; + return; + } + lastODSent.current = { from, to }; + sendMessage(`Replan the trip from ${from} to ${to}`); + }; + const switchVariant = (variantId: RouteVariant['id']) => { if (variantId === selectedVariant || variantSwitching || thinking) return; const lastUserMsg = [...messages].reverse().find(m => m.role === 'user'); @@ -1304,6 +1407,7 @@ export default function TeslaTripPlanner() { sendMessage(chatInput)} chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))} @@ -1491,18 +1595,40 @@ export default function TeslaTripPlanner() { {stops.map((stop, si) => { const isLast = si === stops.length - 1; const leg = legByFromId.get(stop.id); + const hasNextDay = di < itinerary.days.length - 1; + const showNightBlock = isLast && hasNextDay && (stop.type === 'hotel' || stop.type === 'supercharger'); return (
setActiveStopId(stop.id === activeStopId ? null : stop.id)} onHover={(h) => setHoverStopId(h ? stop.id : null)} onSwap={(alt) => swapStop(stop.id, alt)} onRemove={() => removeStop(stop.id)} + onDragStart={(e) => { + e.dataTransfer.setData('text/plain', stop.id); + e.dataTransfer.effectAllowed = 'move'; + setDraggingId(stop.id); + }} + onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} + onDrop={(e) => { + e.preventDefault(); + const dragId = e.dataTransfer.getData('text/plain'); + if (dragId && dragId !== stop.id) reorderStops(dragId, stop.id); + setDraggingId(null); + }} + onDragEnd={() => setDraggingId(null)} /> {!isLast && } + {showNightBlock && ( + setActiveStopId(stop.id)} + /> + )}
); })}