feat(ui): Phase 3b — Customise, Detour, GPX modals

Three new modals wired into the planner with real interactions:

CustomiseStopModal (4 tabs)
- Tab rail (Charger / Overnight / Duration / Things to do / Detour)
  auto-hides tabs that don't apply (e.g. Charger only for chargers,
  Overnight only for hotels, Things to do only when nearby[] exists).
- Charger tab: radio-style picker over stop.chargerOptions with
  kW/stalls/€/network/detour badge per option.
- Overnight tab: lists the current hotel + stop.alternatives as
  swappable options with Choose buttons.
- Duration tab: 48px figure + range slider (10–120 min, snaps to
  presets) with arrive/leave battery % shown live; preset grid
  (Quick top-up / Coffee / Sit-down lunch / Explore / Full charge /
  Skip).
- Things to do tab: checkbox grid over stop.nearby[] for picking what
  you want to do at the stop.
- Apply Changes commits chargeMinutes + durationMin back into the
  itinerary via a new updateStop() handler.

AddDetourOverlay
- Spotlight-style search bar (autofocus, esc to close) with a
  curated POPULAR_DETOURS list (York Minster, Lake District,
  Hadrian's Wall, Beaune town centre, etc.). Insert button adds the
  detour as a new stop in the current day via a new insertDetour()
  handler. The map polyline / leg metrics recompute automatically.

GpxExportModal
- Real downloadable export — GPX (XML), KML (Google Earth), CSV.
- Live preview pane shows the generated file, with line count + KB
  in the footer. Copy puts the file content on the clipboard;
  Download .gpx writes it as a Blob with the right MIME type and
  triggers a real browser download. Filename derived from the trip's
  first/last stop.
- GPX include-toggles for stop notes and nearby places.
- Empty-trip state shows a friendly message instead of an empty pane.

Plumbing
- Modal state lifted into the planner as { kind, stopId? } | null.
- Top bar Export button, the rail's Add stop button, and the bottom
  Add detour link all open the relevant modal.
- The Customise stop button inside the expanded stop card now opens
  the Customise modal for that stop.
- ModalShell handles backdrop, escape-to-close, scroll-clamp, and
  optional header/footer/subtitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:02:22 +01:00
parent ab457dafe2
commit 7187975ca5
+856 -7
View File
@@ -702,10 +702,11 @@ function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPic
);
}
function StopExpansion({ stop, onSwap, onRemove }: {
function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
stop: Stop;
onSwap: (alt: AlternativeStop) => void;
onRemove: () => void;
onCustomise: () => void;
}) {
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null;
@@ -821,7 +822,7 @@ function StopExpansion({ stop, onSwap, onRemove }: {
<div className="flex gap-1.5 mt-3">
<button
onClick={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onCustomise(); }}
className="flex-1 h-8 inline-flex items-center justify-center gap-1.5 rounded-lg text-[11.5px] border transition"
style={{ background: 'var(--gd-panel-2)', borderColor: 'var(--gd-border)', color: 'var(--gd-text)' }}
>
@@ -895,7 +896,7 @@ function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHo
// ─── Stop card (icon-led) ────────────────────────────────────────────────────
function StopCard({
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove,
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise,
onDragStart, onDragOver, onDrop, onDragEnd,
}: {
stop: Stop;
@@ -906,6 +907,7 @@ function StopCard({
onHover: (h: boolean) => void;
onSwap: (alt: AlternativeStop) => void;
onRemove: () => void;
onCustomise: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
@@ -981,7 +983,7 @@ function StopCard({
</div>
)}
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} />}
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} />}
</div>
</div>
</div>
@@ -1029,7 +1031,7 @@ function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon
function TopBar({
origin, destination, onOriginChange, onDestinationChange, onODCommit,
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onVehicleChange, grokStatus,
vehicle, onVehicleChange, grokStatus, onOpenGpx,
}: {
origin: string; destination: string;
onOriginChange: (v: string) => void;
@@ -1040,6 +1042,7 @@ function TopBar({
chips: string[]; onRemoveChip: (i: number) => void;
vehicle: typeof VEHICLES[number]; onVehicleChange: (v: typeof VEHICLES[number]) => void;
grokStatus: { label?: string };
onOpenGpx: () => void;
}) {
return (
<div
@@ -1161,7 +1164,7 @@ function TopBar({
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none" style={{ color: 'var(--gd-text-3)' }} />
</div>
<ChipButton onClick={() => toast.success('GPX exported for your Tesla')}>
<ChipButton onClick={() => onOpenGpx()}>
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
Export
</ChipButton>
@@ -1199,6 +1202,12 @@ export default function TeslaTripPlanner() {
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
const [variantSwitching, setVariantSwitching] = useState(false);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [modal, setModal] = useState<
| { kind: 'customise'; stopId: string }
| { kind: 'detour'; afterStopId?: string }
| { kind: 'gpx' }
| null
>(null);
React.useEffect(() => {
fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {});
@@ -1300,6 +1309,53 @@ export default function TeslaTripPlanner() {
}
};
const updateStop = (stopId: string, patch: Partial<Stop>) => {
const next = structuredClone(itinerary);
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === stopId);
if (idx !== -1) {
d.stops[idx] = { ...d.stops[idx], ...patch };
break;
}
}
setItinerary(next);
};
const insertDetour = (place: { name: string; lat: number; lng: number; type: StopType; description?: string }, afterStopId?: string) => {
const next = structuredClone(itinerary);
const newStop: Stop = {
id: `detour-${Date.now()}`,
name: place.name,
type: place.type,
lat: place.lat,
lng: place.lng,
day: 1,
order: 1,
description: place.description,
combo: null,
};
// Find insertion point — after afterStopId or at the end of the first day
if (afterStopId) {
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === afterStopId);
if (idx !== -1) {
newStop.day = d.day;
d.stops.splice(idx + 1, 0, newStop);
break;
}
}
} else if (next.days.length > 0) {
const lastDay = next.days[next.days.length - 1];
newStop.day = lastDay.day;
lastDay.stops.push(newStop);
} else {
next.days = [{ day: 1, stops: [newStop] }];
}
for (const d of next.days) d.stops.forEach((s, i) => { s.order = i + 1; });
setItinerary(next);
toast.success(`Added ${place.name} to your trip`);
};
const reorderStops = (dragId: string, targetId: string) => {
if (dragId === targetId) return;
const next = structuredClone(itinerary);
@@ -1413,6 +1469,7 @@ export default function TeslaTripPlanner() {
chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))}
vehicle={vehicle} onVehicleChange={setVehicle}
grokStatus={grokStatus}
onOpenGpx={() => setModal({ kind: 'gpx' })}
/>
{variants.length > 0 && (
@@ -1543,6 +1600,7 @@ export default function TeslaTripPlanner() {
</div>
<div className="flex-1" />
<button
onClick={() => setModal({ kind: 'detour' })}
className="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-lg text-[11.5px] whitespace-nowrap"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)', color: 'var(--gd-red)' }}
>
@@ -1608,6 +1666,7 @@ export default function TeslaTripPlanner() {
onHover={(h) => setHoverStopId(h ? stop.id : null)}
onSwap={(alt) => swapStop(stop.id, alt)}
onRemove={() => removeStop(stop.id)}
onCustomise={() => setModal({ kind: 'customise', stopId: stop.id })}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', stop.id);
e.dataTransfer.effectAllowed = 'move';
@@ -1640,7 +1699,8 @@ export default function TeslaTripPlanner() {
{itinerary.days.length > 0 && (
<button
className="w-full h-10 mt-3 rounded-[10px] text-[12px] inline-flex items-center justify-center gap-1.5"
onClick={() => setModal({ kind: 'detour' })}
className="w-full h-10 mt-3 rounded-[10px] text-[12px] inline-flex items-center justify-center gap-1.5 hover:bg-white/[0.02] transition"
style={{ border: '1px dashed var(--gd-border-2)', background: 'transparent', color: 'var(--gd-text-3)' }}
>
<Plus className="w-3 h-3" /> Add a detour or stop
@@ -1675,6 +1735,795 @@ export default function TeslaTripPlanner() {
</aside>
</div>
</div>
{modal?.kind === 'customise' && (
<CustomiseStopModal
stop={allStops.find(s => s.id === modal.stopId) || itinerary.days.flatMap(d => d.stops).find(s => s.id === modal.stopId) || null}
onClose={() => setModal(null)}
onApply={(patch) => { updateStop(modal.stopId, patch); setModal(null); toast.success('Stop updated'); }}
/>
)}
{modal?.kind === 'detour' && (
<AddDetourOverlay
onClose={() => setModal(null)}
onInsert={(place) => insertDetour(place, modal.afterStopId)}
/>
)}
{modal?.kind === 'gpx' && (
<GpxExportModal itinerary={itinerary} onClose={() => setModal(null)} />
)}
</ErrorBoundary>
);
}
// ─── Modal shell ─────────────────────────────────────────────────────────────
function ModalShell({
onClose, width = 720, title, subtitle, footer, children,
}: {
onClose: () => void;
width?: number;
title?: string;
subtitle?: string;
footer?: React.ReactNode;
children: React.ReactNode;
}) {
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 grid place-items-center p-6"
style={{ background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex flex-col overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-2)',
borderRadius: 16,
width,
maxWidth: '100%',
maxHeight: '90vh',
boxShadow: '0 32px 80px rgba(0,0,0,0.5)',
}}
>
{title && (
<div className="px-5 py-4 flex items-center gap-3 flex-shrink-0" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-medium" style={{ letterSpacing: '-0.01em' }}>{title}</div>
{subtitle && <div className="text-[12px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>{subtitle}</div>}
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg grid place-items-center hover:bg-white/5 transition"
style={{ color: 'var(--gd-text-2)' }}
>
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto min-h-0">{children}</div>
{footer && (
<div className="px-5 py-3.5 flex items-center gap-2.5 flex-shrink-0" style={{ borderTop: '1px solid var(--gd-border)' }}>
{footer}
</div>
)}
</div>
</div>
);
}
// ─── Customise Stop modal (Charger / Overnight / Duration / Things / Detour) ─
type CustomiseTab = 'charger' | 'overnight' | 'duration' | 'things' | 'detour';
function CustomiseStopModal({
stop, onClose, onApply,
}: {
stop: Stop | null;
onClose: () => void;
onApply: (patch: Partial<Stop>) => void;
}) {
if (!stop) return null;
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isSleep = stop.type === 'hotel';
const initialTab: CustomiseTab = isCharge ? 'charger' : isSleep ? 'overnight' : 'things';
const [tab, setTab] = React.useState<CustomiseTab>(initialTab);
const [chargeMinutes, setChargeMinutes] = React.useState(stop.chargeMinutes || 30);
const [durationMin, setDurationMin] = React.useState(stop.durationMin || stop.chargeMinutes || 30);
const [pickedNearby, setPickedNearby] = React.useState<Set<string>>(new Set());
const [chosenChargerId, setChosenChargerId] = React.useState<string>(stop.chargerOptions?.find(c => c.isCurrent)?.id || stop.chargerOptions?.[0]?.id || '');
const tabs: { id: CustomiseTab; label: string; icon: React.ComponentType<{ className?: string; size?: number }>; count?: number; show: boolean }[] = [
{ id: 'charger', label: 'Charger', icon: Zap, count: stop.chargerOptions?.length, show: isCharge },
{ id: 'overnight', label: 'Overnight', icon: Bed, count: stop.alternatives?.length, show: isSleep },
{ id: 'duration', label: 'Duration', icon: Clock, show: true },
{ id: 'things', label: 'Things to do', icon: Footprints, count: stop.nearby?.length, show: (stop.nearby?.length ?? 0) > 0 },
{ id: 'detour', label: 'Detour', icon: Route, show: true },
];
return (
<ModalShell
onClose={onClose}
width={780}
title={`Customise · ${stop.name}`}
subtitle={stop.description?.slice(0, 80)}
footer={
<>
<div className="text-[12px] num" style={{ color: 'var(--gd-text-3)' }}>
<span style={{ color: 'var(--gd-text)' }}>Total stop:</span> {durationMin + pickedNearby.size * 8} min
</div>
<div className="flex-1" />
<button
onClick={onClose}
className="h-9 px-3.5 rounded-lg text-[13px] inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
>
Cancel
</button>
<button
onClick={() => onApply({ chargeMinutes, durationMin })}
className="h-9 px-4 rounded-lg text-[13px] font-medium inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
<Sparkles className="w-3.5 h-3.5" /> Apply changes
</button>
</>
}
>
<div className="flex" style={{ minHeight: 460 }}>
{/* Tab rail */}
<div className="w-[200px] py-3.5 px-3 flex flex-col gap-0.5 flex-shrink-0" style={{ borderRight: '1px solid var(--gd-border)' }}>
{tabs.filter(t => t.show).map(t => {
const TI = t.icon;
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left text-[13px] transition"
style={{
background: active ? 'var(--gd-panel-2)' : 'transparent',
color: active ? 'var(--gd-text)' : 'var(--gd-text-2)',
fontWeight: active ? 500 : 400,
}}
>
<TI size={14} className="" />
<span className="flex-1">{t.label}</span>
{t.count != null && (
<span
className="text-[10.5px] num px-1.5 py-px rounded"
style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--gd-text-3)' }}
>
{t.count}
</span>
)}
</button>
);
})}
</div>
{/* Body */}
<div className="flex-1 p-5 overflow-y-auto">
{tab === 'charger' && (
<CustomiseChargerTab
options={stop.chargerOptions || []}
chosenId={chosenChargerId}
onChoose={setChosenChargerId}
/>
)}
{tab === 'overnight' && (
<CustomiseOvernightTab alternatives={stop.alternatives || []} currentName={stop.name} />
)}
{tab === 'duration' && (
<CustomiseDurationTab
value={durationMin}
onChange={setDurationMin}
chargeMinutes={chargeMinutes}
onChargeChange={setChargeMinutes}
isCharge={isCharge}
arrivePct={stop.estArrivalBattery}
/>
)}
{tab === 'things' && (
<CustomiseThingsTab
nearby={stop.nearby || []}
picked={pickedNearby}
onTogglePick={(name) => {
const next = new Set(pickedNearby);
if (next.has(name)) next.delete(name); else next.add(name);
setPickedNearby(next);
}}
/>
)}
{tab === 'detour' && <CustomiseDetourTab />}
</div>
</div>
</ModalShell>
);
}
function CustomiseChargerTab({ options, chosenId, onChoose }: {
options: ChargerOption[]; chosenId: string; onChoose: (id: string) => void;
}) {
if (options.length === 0) {
return <EmptyTab text="No alternative chargers near this stop." />;
}
return (
<div>
<SectionLabel>Choose a charger</SectionLabel>
<div className="flex flex-col gap-2">
{options.map(o => {
const sel = o.id === chosenId;
return (
<div
key={o.id}
onClick={() => onChoose(o.id)}
className="p-3.5 rounded-[12px] cursor-pointer transition"
style={{
background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${sel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
}}
>
<div className="flex items-start gap-3">
<div
className="w-[18px] h-[18px] rounded-full grid place-items-center flex-shrink-0 mt-0.5"
style={{
border: `2px solid ${sel ? 'var(--gd-red)' : 'var(--gd-border-2)'}`,
background: sel ? 'var(--gd-red)' : 'transparent',
}}
>
{sel && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-[14px] font-medium">{o.name}</div>
{o.badge && (
<span
className="text-[9.5px] px-1.5 py-px rounded whitespace-nowrap"
style={{ color: o.isCurrent ? 'var(--gd-red)' : 'var(--gd-amber)', border: `1px solid ${o.isCurrent ? 'var(--gd-red-line)' : 'rgba(251,191,36,0.4)'}` }}
>
{o.badge}
</span>
)}
</div>
<div className="text-[11.5px] mt-1 flex gap-3.5 flex-wrap num" style={{ color: 'var(--gd-text-3)' }}>
<span className="inline-flex items-center gap-1.5">
<Zap className="w-3 h-3" style={{ color: 'var(--gd-green)' }} /> {o.kw} kW · {o.stalls} stalls
</span>
<span className="inline-flex items-center gap-1.5">
<Euro className="w-3 h-3" /> {o.pricePerKwh.toFixed(2)}/kWh
</span>
{o.detourMin > 0 ? (
<span style={{ color: 'var(--gd-amber)' }}>+{o.detourMin} min detour</span>
) : (
<span style={{ color: 'var(--gd-green)' }}>On route</span>
)}
{o.network && <span>{o.network}</span>}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
function CustomiseOvernightTab({ alternatives, currentName }: { alternatives: AlternativeStop[]; currentName: string }) {
if (alternatives.length === 0) {
return <EmptyTab text="No alternative overnight options were suggested for this stop." />;
}
return (
<div>
<SectionLabel>Choose where to sleep</SectionLabel>
<div className="flex flex-col gap-2.5">
<div
className="p-3.5 rounded-[12px]"
style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.4)' }}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-[9px] grid place-items-center" style={{ background: 'rgba(96,165,250,0.2)' }}>
<Bed className="w-4 h-4" style={{ color: 'var(--gd-blue)' }} />
</div>
<div className="flex-1">
<div className="text-[14px] font-medium">{currentName}</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>Currently selected</div>
</div>
<span className="text-[10.5px]" style={{ color: 'var(--gd-blue)' }}>Current</span>
</div>
</div>
{alternatives.map(a => (
<div
key={a.id}
className="p-3.5 rounded-[12px]"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-[9px] grid place-items-center" style={{ background: 'var(--gd-panel-2)' }}>
<Bed className="w-4 h-4" style={{ color: 'var(--gd-text-2)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium truncate">{a.name}</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>{a.reason || a.description}</div>
</div>
<button
className="h-7 px-2.5 rounded-lg text-[11px]"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
Choose
</button>
</div>
</div>
))}
</div>
</div>
);
}
function CustomiseDurationTab({ value, onChange, chargeMinutes, onChargeChange, isCharge, arrivePct }: {
value: number; onChange: (v: number) => void;
chargeMinutes: number; onChargeChange: (v: number) => void;
isCharge: boolean; arrivePct?: number;
}) {
const leavePct = arrivePct != null ? Math.min(100, arrivePct + Math.round(chargeMinutes * 1.4)) : null;
const sliderPct = ((value - 10) / 110) * 100;
return (
<div>
<SectionLabel>How long here?</SectionLabel>
<div className="flex items-baseline gap-2 mb-5">
<div className="text-[48px] font-light num" style={{ letterSpacing: '-0.03em' }}>{value}</div>
<div className="text-[16px]" style={{ color: 'var(--gd-text-2)' }}>minutes</div>
<div className="flex-1" />
{isCharge && arrivePct != null && (
<div className="text-[12px] num" style={{ color: 'var(--gd-text-3)' }}>
arrive {arrivePct}% · leave <span style={{ color: 'var(--gd-green)' }}>{leavePct}%</span>
</div>
)}
</div>
<div className="relative h-10 mb-6">
<input
type="range" min={10} max={120} step={5} value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
className="absolute inset-0 w-full h-10 opacity-0 z-10 cursor-pointer"
/>
<div className="absolute top-[18px] left-0 right-0 h-1 rounded" style={{ background: 'var(--gd-border)' }} />
<div className="absolute top-[18px] left-0 h-1 rounded" style={{ width: `${sliderPct}%`, background: 'var(--gd-red)' }} />
<div
className="absolute top-3 w-4 h-4 rounded-full"
style={{
left: `calc(${sliderPct}% - 8px)`,
background: '#fff',
border: '3px solid var(--gd-red)',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
/>
{[15, 30, 45, 60, 90, 120].map(t => (
<div
key={t}
className="absolute top-7 text-[10px] num w-7 text-center"
style={{ left: `calc(${((t - 10) / 110) * 100}% - 14px)`, color: 'var(--gd-text-3)' }}
>
{t}m
</div>
))}
</div>
<SectionLabel>Presets</SectionLabel>
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'Quick top-up', mins: 15, sub: 'just enough to reach next' },
{ label: 'Coffee + restroom', mins: 30, sub: 'short walk, restrooms' },
{ label: 'Sit-down lunch', mins: 60, sub: 'café or restaurant nearby' },
{ label: 'Explore the town', mins: 90, sub: 'old town loop + lunch' },
{ label: 'Full charge', mins: 120, sub: 'to 100% if needed' },
{ label: 'Skip & risk it', mins: 10, sub: 'bypass entirely', warn: true },
].map(p => (
<button
key={p.label}
onClick={() => { onChange(p.mins); if (isCharge) onChargeChange(p.mins); }}
className="p-3 rounded-[9px] text-left"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<div className="text-[12px] font-medium" style={{ color: p.warn ? 'var(--gd-amber)' : 'var(--gd-text)' }}>{p.label}</div>
<div className="text-[10.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>{p.sub}</div>
</button>
))}
</div>
</div>
);
}
function CustomiseThingsTab({ nearby, picked, onTogglePick }: {
nearby: NearbyPlace[]; picked: Set<string>; onTogglePick: (name: string) => void;
}) {
if (nearby.length === 0) {
return <EmptyTab text="No nearby places suggested for this stop." />;
}
return (
<div>
<SectionLabel>Pick what you want to do here</SectionLabel>
<div className="grid grid-cols-2 gap-2">
{nearby.map((n, i) => {
const sel = picked.has(n.name);
return (
<div
key={i}
onClick={() => onTogglePick(n.name)}
className="p-3 rounded-[10px] flex items-center gap-2.5 cursor-pointer transition"
style={{
background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${sel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
}}
>
<div
className="w-9 h-9 rounded-[9px] grid place-items-center flex-shrink-0 text-[15px] leading-none"
style={{ background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel-2)' }}
>
{AMENITY_ICONS[n.icon] || '📍'}
</div>
<div className="flex-1 min-w-0">
<div className="text-[12.5px] font-medium truncate">{n.name}</div>
<div className="text-[10.5px] mt-0.5 truncate" style={{ color: 'var(--gd-text-3)' }}>{n.detail}</div>
</div>
<div
className="w-[18px] h-[18px] rounded grid place-items-center flex-shrink-0"
style={{
border: `1.5px solid ${sel ? 'var(--gd-red)' : 'var(--gd-border-2)'}`,
background: sel ? 'var(--gd-red)' : 'transparent',
}}
>
{sel && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth={3}><path d="M5 12l5 5 9-11" /></svg>}
</div>
</div>
);
})}
</div>
</div>
);
}
function CustomiseDetourTab() {
return (
<div>
<SectionLabel>Add a detour from this stop</SectionLabel>
<div className="p-4 rounded-[12px] flex items-center gap-2" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<MapPin className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-3)' }} />
<input
placeholder="Search for a town, attraction, restaurant…"
className="flex-1 bg-transparent outline-none border-none text-[13px]"
style={{ color: 'var(--gd-text)' }}
/>
</div>
<div className="text-[11px] mt-3" style={{ color: 'var(--gd-text-3)' }}>
Search is mocked use the "Add stop" button in the rail header to insert a detour from the popular-places list.
</div>
</div>
);
}
function EmptyTab({ text }: { text: string }) {
return (
<div className="h-full grid place-items-center min-h-[260px]">
<div className="text-center max-w-xs">
<div className="text-[13px]" style={{ color: 'var(--gd-text-2)' }}>{text}</div>
</div>
</div>
);
}
// ─── Add Detour overlay ──────────────────────────────────────────────────────
const POPULAR_DETOURS: { name: string; lat: number; lng: number; type: StopType; description: string; detourMin: number }[] = [
{ name: 'York Minster', lat: 53.962, lng: -1.082, type: 'attraction', description: 'Gothic cathedral · 12th century', detourMin: 25 },
{ name: 'Lake District National Park', lat: 54.4609, lng: -3.0886, type: 'viewpoint', description: 'England\'s largest national park', detourMin: 45 },
{ name: 'Tebay Services Farm Shop', lat: 54.4331, lng: -2.6049, type: 'restaurant', description: 'Independent farm shop on the M6', detourMin: 5 },
{ name: 'Beaune town centre', lat: 47.0241, lng: 4.8398, type: 'attraction', description: 'Cobbled lanes, mustard shop, hospices', detourMin: 4 },
{ name: 'Hadrian\'s Wall', lat: 55.0114, lng: -2.2854, type: 'attraction', description: 'Roman wall · UNESCO World Heritage', detourMin: 30 },
{ name: 'Bamburgh Castle', lat: 55.6086, lng: -1.7102, type: 'attraction', description: 'Coastal castle · Northumbrian icon', detourMin: 35 },
];
function AddDetourOverlay({
onClose, onInsert,
}: {
onClose: () => void;
onInsert: (place: typeof POPULAR_DETOURS[number]) => void;
}) {
const [query, setQuery] = React.useState('');
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const filtered = POPULAR_DETOURS.filter(p => !query || p.name.toLowerCase().includes(query.toLowerCase()));
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex justify-center items-start px-6 pt-[90px]"
style={{ background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-[640px] max-w-full overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
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.5 flex items-center gap-3" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<MapPin className="w-4 h-4" style={{ color: 'var(--gd-red)' }} />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Add a stop — city, attraction, charger, restaurant…"
className="flex-1 bg-transparent outline-none text-[15px]"
style={{ color: 'var(--gd-text)' }}
/>
<kbd className="mono text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--gd-text-3)', border: '1px solid var(--gd-border)' }}>esc</kbd>
</div>
<div className="p-2 max-h-[380px] overflow-y-auto">
{filtered.length === 0 ? (
<div className="text-[12px] p-4 text-center" style={{ color: 'var(--gd-text-3)' }}>
No matches in the popular-detours list. Real search coming soon.
</div>
) : (
filtered.map(r => (
<div
key={r.name}
className="px-3 py-2.5 rounded-[9px] flex items-center gap-3 hover:bg-white/5 transition"
>
<div className="w-8 h-8 rounded-[7px] grid place-items-center flex-shrink-0" style={{ background: 'var(--gd-panel-2)' }}>
<MapPin className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{r.name}</div>
<div className="text-[11px] truncate" style={{ color: 'var(--gd-text-3)' }}>{r.description} · +{r.detourMin} min</div>
</div>
<button
onClick={() => { onInsert(r); onClose(); }}
className="h-7 px-2.5 rounded-lg text-[11px] inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)', border: '1px solid var(--gd-red-line)' }}
>
<Plus className="w-3 h-3" /> Insert
</button>
</div>
))
)}
</div>
</div>
</div>
);
}
// ─── GPX Export modal ────────────────────────────────────────────────────────
type ExportFormat = 'gpx' | 'kml' | 'csv';
function generateGpx(itinerary: Itinerary, includeNotes: boolean, includeNearby: boolean): string {
const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number');
const header = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Grok Drive" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${escapeXml(allStops[0]?.name || 'Trip')}${escapeXml(allStops[allStops.length - 1]?.name || 'Destination')}</name>
<desc>${itinerary.summary.totalDistanceKm} km · ${itinerary.summary.estDriveHours}h drive · ${itinerary.summary.estChargeHours}h charging</desc>
<time>${new Date().toISOString()}</time>
</metadata>
<trk>
<name>Grok Drive route</name>
<trkseg>`;
const points = allStops.map(s => {
const inner: string[] = [
` <name>${escapeXml(s.name)}</name>`,
` <type>${escapeXml(s.type)}</type>`,
];
if (includeNotes && s.description) inner.push(` <desc>${escapeXml(s.description)}</desc>`);
if (includeNearby && s.nearby && s.nearby.length > 0) {
inner.push(` <cmt>Nearby: ${escapeXml(s.nearby.slice(0, 3).map(n => n.name).join(', '))}</cmt>`);
}
return ` <trkpt lat="${s.lat.toFixed(5)}" lon="${s.lng.toFixed(5)}">\n${inner.join('\n')}\n </trkpt>`;
}).join('\n');
return `${header}\n${points}\n </trkseg>\n </trk>\n</gpx>`;
}
function generateKml(itinerary: Itinerary): string {
const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number');
const placemarks = allStops.map(s => ` <Placemark>
<name>${escapeXml(s.name)}</name>
<description>${escapeXml(s.description || s.type)}</description>
<Point><coordinates>${s.lng.toFixed(5)},${s.lat.toFixed(5)},0</coordinates></Point>
</Placemark>`).join('\n');
const lineCoords = allStops.map(s => `${s.lng.toFixed(5)},${s.lat.toFixed(5)},0`).join(' ');
return `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Grok Drive route</name>
${placemarks}
<Placemark><name>Route</name><LineString><coordinates>${lineCoords}</coordinates></LineString></Placemark>
</Document>
</kml>`;
}
function generateCsv(itinerary: Itinerary): string {
const rows = [
'day,order,name,type,lat,lng,charge_minutes,arrive_battery_pct,combo,description',
];
for (const d of itinerary.days) {
for (const s of d.stops) {
rows.push([
d.day, s.order, q(s.name), s.type, s.lat ?? '', s.lng ?? '',
s.chargeMinutes ?? '', s.estArrivalBattery ?? '',
q(s.combo || ''), q(s.description || ''),
].join(','));
}
}
return rows.join('\n');
function q(v: string) { return `"${v.replace(/"/g, '""')}"`; }
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function downloadFile(filename: string, content: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function GpxExportModal({ itinerary, onClose }: { itinerary: Itinerary; onClose: () => void }) {
const [format, setFormat] = React.useState<ExportFormat>('gpx');
const [includeNotes, setIncludeNotes] = React.useState(true);
const [includeNearby, setIncludeNearby] = React.useState(false);
const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number');
const content = format === 'gpx'
? generateGpx(itinerary, includeNotes, includeNearby)
: format === 'kml'
? generateKml(itinerary)
: generateCsv(itinerary);
const baseName = `grok-drive-${(allStops[0]?.name || 'trip').toLowerCase().replace(/\s+/g, '-')}-${(allStops[allStops.length - 1]?.name || 'route').toLowerCase().replace(/\s+/g, '-')}`;
const filename = `${baseName}.${format}`;
const handleDownload = () => {
const mime = format === 'gpx' ? 'application/gpx+xml' : format === 'kml' ? 'application/vnd.google-earth.kml+xml' : 'text/csv';
downloadFile(filename, content, mime);
toast.success(`Downloaded ${filename}`);
};
if (allStops.length === 0) {
return (
<ModalShell
onClose={onClose}
width={520}
title="Export trip"
footer={
<>
<div className="flex-1" />
<button
onClick={onClose}
className="h-9 px-3.5 rounded-lg text-[13px]"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
Close
</button>
</>
}
>
<div className="p-6 text-center">
<div className="text-[13px]" style={{ color: 'var(--gd-text-2)' }}>
Plan a trip first there's nothing to export yet.
</div>
</div>
</ModalShell>
);
}
return (
<ModalShell
onClose={onClose}
width={820}
title="Export trip"
subtitle="Send this trip to your car, phone, or another planning app"
footer={
<>
<div className="text-[12px] num" style={{ color: 'var(--gd-text-3)' }}>
{content.split('\n').length} lines · ~{(content.length / 1024).toFixed(1)} KB
</div>
<div className="flex-1" />
<button
onClick={() => { navigator.clipboard.writeText(content); toast.success('Copied to clipboard'); }}
className="h-9 px-3.5 rounded-lg text-[13px] inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<Share2 className="w-3.5 h-3.5" /> Copy
</button>
<button
onClick={handleDownload}
className="h-9 px-4 rounded-lg text-[13px] font-medium inline-flex items-center gap-1.5"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
<Download className="w-3.5 h-3.5" /> Download .{format}
</button>
</>
}
>
<div className="flex" style={{ minHeight: 460 }}>
<div className="w-[260px] py-5 px-5 flex flex-col gap-4 flex-shrink-0" style={{ borderRight: '1px solid var(--gd-border)' }}>
<div>
<SectionLabel>Format</SectionLabel>
{([
{ id: 'gpx', name: 'GPX', detail: 'Tesla, car nav, ABRP, most apps' },
{ id: 'kml', name: 'KML', detail: 'Google Earth / Google Maps' },
{ id: 'csv', name: 'CSV', detail: 'Spreadsheet of stops' },
] as { id: ExportFormat; name: string; detail: string }[]).map(f => {
const sel = format === f.id;
return (
<div
key={f.id}
onClick={() => setFormat(f.id)}
className="px-3 py-2.5 rounded-lg mb-1 cursor-pointer"
style={{
background: sel ? 'var(--gd-red-soft)' : 'transparent',
border: `1px solid ${sel ? 'var(--gd-red-line)' : 'transparent'}`,
}}
>
<div className="text-[12.5px] font-medium">{f.name}</div>
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>{f.detail}</div>
</div>
);
})}
</div>
{format === 'gpx' && (
<div>
<SectionLabel>Include</SectionLabel>
<CheckRow label="Stop notes & descriptions" value={includeNotes} onChange={setIncludeNotes} />
<CheckRow label="Nearby places" value={includeNearby} onChange={setIncludeNearby} />
</div>
)}
</div>
<div className="flex-1 relative overflow-hidden" style={{ background: 'var(--gd-bg)' }}>
<div className="px-4 py-2.5 flex items-center gap-2.5 text-[11px]" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<span className="mono" style={{ color: 'var(--gd-text-3)' }}>{filename}</span>
<div className="flex-1" />
<span className="num" style={{ color: 'var(--gd-text-3)' }}>{allStops.length} waypoints</span>
</div>
<pre
className="m-0 p-4 overflow-auto mono text-[11px] leading-[1.5]"
style={{ color: 'var(--gd-text-2)', maxHeight: 420 }}
>
{content}
</pre>
</div>
</div>
</ModalShell>
);
}
function CheckRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-2 py-1.5 text-[12px] cursor-pointer" style={{ color: 'var(--gd-text-2)' }}>
<div
onClick={(e) => { e.preventDefault(); onChange(!value); }}
className="w-4 h-4 rounded grid place-items-center"
style={{
border: `1.5px solid ${value ? 'var(--gd-text)' : 'var(--gd-border-2)'}`,
background: value ? 'var(--gd-text)' : 'transparent',
}}
>
{value && <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#0a0a0c" strokeWidth={3.5}><path d="M5 12l5 5 9-11" /></svg>}
</div>
<span>{label}</span>
</label>
);
}