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:
@@ -702,10 +702,11 @@ function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPic
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StopExpansion({ stop, onSwap, onRemove }: {
|
function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
|
||||||
stop: Stop;
|
stop: Stop;
|
||||||
onSwap: (alt: AlternativeStop) => void;
|
onSwap: (alt: AlternativeStop) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCustomise: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
|
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
|
||||||
const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null;
|
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">
|
<div className="flex gap-1.5 mt-3">
|
||||||
<button
|
<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"
|
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)' }}
|
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) ────────────────────────────────────────────────────
|
// ─── Stop card (icon-led) ────────────────────────────────────────────────────
|
||||||
function StopCard({
|
function StopCard({
|
||||||
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove,
|
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise,
|
||||||
onDragStart, onDragOver, onDrop, onDragEnd,
|
onDragStart, onDragOver, onDrop, onDragEnd,
|
||||||
}: {
|
}: {
|
||||||
stop: Stop;
|
stop: Stop;
|
||||||
@@ -906,6 +907,7 @@ function StopCard({
|
|||||||
onHover: (h: boolean) => void;
|
onHover: (h: boolean) => void;
|
||||||
onSwap: (alt: AlternativeStop) => void;
|
onSwap: (alt: AlternativeStop) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCustomise: () => void;
|
||||||
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;
|
||||||
@@ -981,7 +983,7 @@ function StopCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} />}
|
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1029,7 +1031,7 @@ function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon
|
|||||||
function TopBar({
|
function TopBar({
|
||||||
origin, destination, onOriginChange, onDestinationChange, onODCommit,
|
origin, destination, onOriginChange, onDestinationChange, onODCommit,
|
||||||
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
|
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
|
||||||
vehicle, onVehicleChange, grokStatus,
|
vehicle, onVehicleChange, grokStatus, onOpenGpx,
|
||||||
}: {
|
}: {
|
||||||
origin: string; destination: string;
|
origin: string; destination: string;
|
||||||
onOriginChange: (v: string) => void;
|
onOriginChange: (v: string) => void;
|
||||||
@@ -1040,6 +1042,7 @@ function TopBar({
|
|||||||
chips: string[]; onRemoveChip: (i: number) => void;
|
chips: string[]; onRemoveChip: (i: number) => void;
|
||||||
vehicle: typeof VEHICLES[number]; onVehicleChange: (v: typeof VEHICLES[number]) => void;
|
vehicle: typeof VEHICLES[number]; onVehicleChange: (v: typeof VEHICLES[number]) => void;
|
||||||
grokStatus: { label?: string };
|
grokStatus: { label?: string };
|
||||||
|
onOpenGpx: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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)' }} />
|
<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>
|
</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)' }} />
|
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||||
Export
|
Export
|
||||||
</ChipButton>
|
</ChipButton>
|
||||||
@@ -1199,6 +1202,12 @@ export default function TeslaTripPlanner() {
|
|||||||
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
||||||
const [variantSwitching, setVariantSwitching] = useState(false);
|
const [variantSwitching, setVariantSwitching] = useState(false);
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {});
|
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) => {
|
const reorderStops = (dragId: string, targetId: string) => {
|
||||||
if (dragId === targetId) return;
|
if (dragId === targetId) return;
|
||||||
const next = structuredClone(itinerary);
|
const next = structuredClone(itinerary);
|
||||||
@@ -1413,6 +1469,7 @@ export default function TeslaTripPlanner() {
|
|||||||
chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))}
|
chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))}
|
||||||
vehicle={vehicle} onVehicleChange={setVehicle}
|
vehicle={vehicle} onVehicleChange={setVehicle}
|
||||||
grokStatus={grokStatus}
|
grokStatus={grokStatus}
|
||||||
|
onOpenGpx={() => setModal({ kind: 'gpx' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{variants.length > 0 && (
|
{variants.length > 0 && (
|
||||||
@@ -1543,6 +1600,7 @@ export default function TeslaTripPlanner() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<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"
|
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)' }}
|
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)}
|
onHover={(h) => setHoverStopId(h ? stop.id : null)}
|
||||||
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 })}
|
||||||
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';
|
||||||
@@ -1640,7 +1699,8 @@ export default function TeslaTripPlanner() {
|
|||||||
|
|
||||||
{itinerary.days.length > 0 && (
|
{itinerary.days.length > 0 && (
|
||||||
<button
|
<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)' }}
|
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
|
<Plus className="w-3 h-3" /> Add a detour or stop
|
||||||
@@ -1675,6 +1735,795 @@ export default function TeslaTripPlanner() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user