From 5eb83e9b4bfc933456c66db3171b4fbf84567cfc Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 26 Apr 2026 21:14:39 +0000 Subject: [PATCH] feat(panel): single-panel architecture with UX refinements Major refactor consolidating two-panel layout (Routes/Contacts + floating PlaceDetail) into one 400px left column with state-driven content. Architecture: - New PlaceCard component for preview and stop cards (collapsible) - Panel states: IDLE, PREVIEW, ROUTING, PREVIEW_ROUTING, ROUTE_CALCULATED - usePanelState selector in store.js derives state from selectedPlace/stops/route - StopList now renders stops as PlaceCard with variant=stop - PlaceDetail.jsx removed from App.jsx (content moved to PlaceCard) UX refinements: - Panel width 400px (was 360px) to fit buttons on one line - Map zoom padding updated to 420px for wider panel - Body text bumped to text-sm (14px) for readability - Get Directions button hidden when 2+ stops (route auto-calculates) - PlaceCard title prefers feature name (raw.name) over formatted address - Preview card shows above route during PREVIEW_ROUTING state - Directions flow no longer shows toast when GPS denied --- src/App.jsx | 4 +- src/components/MapView.jsx | 6 +- src/components/Panel.jsx | 74 ++++-- src/components/PlaceCard.jsx | 434 +++++++++++++++++++++++++++++++++++ src/components/StopList.jsx | 190 +++++++++------ src/store.js | 18 ++ 6 files changed, 626 insertions(+), 100 deletions(-) create mode 100644 src/components/PlaceCard.jsx diff --git a/src/App.jsx b/src/App.jsx index ca6d8cf..94a5de8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,7 +5,7 @@ import { requestRoute } from './api' import { decodePolyline } from './utils/decode' import MapView from './components/MapView' import Panel from './components/Panel' -import PlaceDetail from './components/PlaceDetail' + import ContactModal from './components/ContactModal' import LayerControl from './components/LayerControl' import LocateButton from './components/LocateButton' @@ -88,7 +88,7 @@ export default function App() {
- + diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index cc717de..9cc0498 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1352,8 +1352,8 @@ const MapView = forwardRef(function MapView(_, ref) { (b, c) => b.extend(c), new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) ) - const hasDetail = useStore.getState().selectedPlace != null - const leftPad = hasDetail ? 700 : 340 + // Single-panel: no floating detail + const leftPad = 420 // 360px panel + margin map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) } } @@ -1426,7 +1426,7 @@ const MapView = forwardRef(function MapView(_, ref) { (b, s) => b.extend([s.lon, s.lat]), new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) ) - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } }) + map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } }) } } }, [stops, route, gpsOrigin, geoPermission]) diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 43c58a5..d06c1de 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,15 +1,18 @@ import { useRef, useCallback, useEffect, useState } from 'react' import { Sun, Moon } from 'lucide-react' -import { useStore } from '../store' +import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' import SearchBar from './SearchBar' import StopList from './StopList' import ModeSelector from './ModeSelector' import ManeuverList from './ManeuverList' import ContactList from './ContactList' +import { PlaceCard } from './PlaceCard' import { requestOptimizedRoute } from '../api' export default function Panel({ onManeuverClick }) { + const selectedPlace = useStore((s) => s.selectedPlace) + const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) const stops = useStore((s) => s.stops) const mode = useStore((s) => s.mode) const route = useStore((s) => s.route) @@ -29,6 +32,8 @@ export default function Panel({ onManeuverClick }) { const activeTab = useStore((s) => s.activeTab) const setActiveTab = useStore((s) => s.setActiveTab) + const panelState = usePanelState() + const [isMobile, setIsMobile] = useState(false) const [optimizing, setOptimizing] = useState(false) const sheetRef = useRef(null) @@ -121,38 +126,62 @@ export default function Panel({ onManeuverClick }) { const showOptimize = effectiveCount >= 3 + // Determine what to show based on panel state + const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING' + const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED' + const showManeuvers = panelState === 'ROUTE_CALCULATED' + const showEmptyState = panelState === 'IDLE' + + // Routes tab content - now state-driven const routesContent = ( <> -
- -
- - {stops.length >= 1 && ( -
- - {showOptimize && ( - - )} + {/* Preview card when place is selected */} + {showPreviewCard && selectedPlace && ( +
+
)} - {(route || routeLoading || routeError) && ( + {/* Route section with stops */} + {showRouteSection && ( + <> +
+ +
+ +
+ + {showOptimize && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && (
)} - {stops.length === 0 && !route && ( + {/* Empty state */} + {showEmptyState && (
-

Search and add stops to build your route

+

Search or tap the map to explore

)} @@ -196,12 +225,13 @@ export default function Panel({ onManeuverClick }) {
) - // Desktop: side panel + // Desktop: side panel (now 360px to accommodate PlaceCard) if (!isMobile) { return (
0 ? `${h}h ${m}m` : `${h}h` +} + +const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + +function parseHours(hoursStr) { + try { + const oh = new OpeningHours(hoursStr, { address: { country_code: "us", state: "Idaho" } }) + const now = new Date() + const isOpen = oh.getState(now) + const nextChange = oh.getNextChange(now) + let todayStr = "" + if (isOpen) { + todayStr = "Open now" + if (nextChange) { + const closeTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + todayStr += " \u00b7 Closes " + closeTime + } + } else { + todayStr = "Closed" + if (nextChange) { + const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + const isTodayOpen = nextChange.getDate() === now.getDate() + todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + openTime + } + } + const week = [] + for (let d = 0; d < 7; d++) { + const date = new Date(now) + const diff = (d - now.getDay() + 7) % 7 + date.setDate(now.getDate() + diff) + date.setHours(0, 0, 0, 0) + const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000)) + if (intervals.length === 0) { + week.push({ day: DAY_SHORT[d], hours: "Closed", isTodayRow: d === now.getDay() }) + } else { + const parts = intervals.map(([start, end]) => { + const s = start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + const e = end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + return s + " \u2013 " + e + }) + week.push({ day: DAY_SHORT[d], hours: parts.join(", "), isTodayRow: d === now.getDay() }) + } + } + return { isOpen, todayStr, week } + } catch { + return null + } +} + +function formatPhone(phone) { + if (!phone) return null + const digits = phone.replace(/[^\d]/g, "") + if (digits.length === 11 && digits[0] === "1") { + return "(" + digits.slice(1, 4) + ") " + digits.slice(4, 7) + "-" + digits.slice(7) + } + if (digits.length === 10) { + return "(" + digits.slice(0, 3) + ") " + digits.slice(3, 6) + "-" + digits.slice(6) + } + return phone +} + +function wheelchairLabel(val) { + if (!val) return null + const map = { yes: "Accessible", limited: "Limited access", no: "Not accessible" } + return map[val.toLowerCase()] || null +} + +function wikiUrl(wp) { + if (!wp) return null + const [lang, ...rest] = wp.split(":") + const title = rest.join(":").replace(/ /g, "_") + return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title) +} + +function wikiLabel(wp) { + if (!wp) return null + const [, ...rest] = wp.split(":") + return rest.join(":").replace(/_/g, " ") +} + +function DetailSection({ label, icon: Icon, first, children }) { + return ( +
+
+ + {label} +
+ {children} +
+ ) +} + +function HoursDisplay({ hoursStr, first }) { + const [expanded, setExpanded] = useState(false) + const parsed = parseHours(hoursStr) + if (!parsed) return null + const { isOpen, todayStr, week } = parsed + return ( + + + {expanded && ( +
+ {week.map((w) => ( +
+ {w.day} + {w.hours} +
+ ))} +
+ )} +
+ ) +} + +function LandclassSection({ data }) { + if (!data || !data.summary) return null + return ( +
+ +
+ {data.summary} + {data.unit_name && {data.unit_name}} +
+
+ ) +} + +function PrivateLandIndicator({ data }) { + if (!data || data.gap_status !== "4") return null + return ( +
+ Private land — permission required +
+ ) +} + +function EnrichmentSkeleton() { + return ( +
+
+
+
+
+ ) +} + +function EnrichmentSections({ details }) { + if (!details) return null + const { category, extratags } = details + const et = extratags || {} + const hasAbout = category + const hasHours = et.opening_hours + const hasContact = et.phone || et.website || et.email + const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway + const hasLinks = et.wikipedia || et.wikidata + if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null + let idx = 0 + return ( +
+ {hasAbout && ( + + {category} + + )} + {hasHours && } + {hasContact && ( + + + + )} + {hasDetails && ( + +
+ {et.cuisine && Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")}} + {et.operator && Operated by {et.operator}} + {et.fee && {et.fee === "no" ? "Free" : "Fee: " + et.fee}} + {et.wheelchair && wheelchairLabel(et.wheelchair) && {wheelchairLabel(et.wheelchair)}} + {et.takeaway === "yes" && Takeaway available} +
+
+ )} + {hasLinks && ( + +
+ {et.wikipedia && wikiUrl(et.wikipedia) && {wikiLabel(et.wikipedia)}} + {et.wikidata && View on Wikidata} +
+
+ )} +
+ ) +} + +function CopyPopover({ address, place, onClose }) { + const ref = useRef(null) + useEffect(() => { + function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() } + document.addEventListener("mousedown", handleClick) + return () => document.removeEventListener("mousedown", handleClick) + }, [onClose]) + const copyAddress = () => { + const text = [place.name, address].filter(Boolean).join("\n") + navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy")) + onClose() + } + const copyCoords = () => { + const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6) + navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy")) + onClose() + } + return ( +
+ + +
+ ) +} + +export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) { + const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore() + const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null }) + const [placeDetails, setPlaceDetails] = useState(null) + const [driveTime, setDriveTime] = useState(null) + const [nearbyLabel, setNearbyLabel] = useState(null) + const [landclass, setLandclass] = useState(null) + const [copyOpen, setCopyOpen] = useState(false) + + const placeLat = place?.lat + const placeLon = place?.lon + const osmType = place?.raw?.osm_type + const osmId = place?.raw?.osm_id + const wikidataId = place?.wikidata || place?.raw?.wikidata + + useEffect(() => { + if (placeLat == null || placeLon == null) return + let cancelled = false + fetchElevation(placeLat, placeLon).then((h) => { if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h }) }) + return () => { cancelled = true } + }, [placeLat, placeLon]) + + useEffect(() => { + if (!hasFeature("has_nominatim_details") || !osmType || !osmId) { setPlaceDetails(null); return } + const controller = new AbortController() + setPlaceDetails("loading") + fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => { + if (!controller.signal.aborted) { + setPlaceDetails(data || null) + if (data?.boundary) { + const current = useStore.getState().selectedPlace + if (current && current.lat === placeLat && current.lon === placeLon) { + useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary }) + } + } + } + }) + return () => controller.abort() + }, [osmType, osmId, placeLat, placeLon]) + + useEffect(() => { + if (osmType && osmId) return + if (!wikidataId) return + const controller = new AbortController() + fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => { + if (!controller.signal.aborted && data) { + setPlaceDetails((prev) => ({ + ...(prev === "loading" ? {} : prev || {}), + description: data.description, + population: data.population, + osm_relation_id: data.osm_relation_id, + extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags }, + })) + if (data?.boundary) { + const current = useStore.getState().selectedPlace + if (current && current.lat === placeLat && current.lon === placeLon) { + useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary }) + } + } + } + }) + return () => controller.abort() + }, [wikidataId, osmType, osmId, placeLat, placeLon]) + + useEffect(() => { + if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return } + setDriveTime(null) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 3000) + fetchDriveTime(userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal).then((time) => { if (!controller.signal.aborted) setDriveTime(time) }) + return () => { controller.abort(); clearTimeout(timeout) } + }, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon]) + + useEffect(() => { + if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return } + const controller = new AbortController() + fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => { + if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label) + else if (!controller.signal.aborted) setNearbyLabel(null) + }) + return () => controller.abort() + }, [placeLat, placeLon]) + + useEffect(() => { + if (!hasFeature("has_landclass") || placeLat == null || placeLon == null) { setLandclass(null); return } + const controller = new AbortController() + fetchLandclass(placeLat, placeLon, controller.signal).then((data) => { + if (!controller.signal.aborted && data) { + setLandclass(data) + if (data.summary && useStore.getState().selectedPlace?.name === "Dropped pin") { + const current = useStore.getState().selectedPlace + useStore.getState().setSelectedPlace({ ...current, name: data.summary }) + } + } else if (!controller.signal.aborted) setLandclass(null) + }) + return () => controller.abort() + }, [placeLat, placeLon]) + + if (!place) return null + + const address = buildAddress(place) + const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon) + const elevation = !elevLoading ? elevResult.value : null + const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null + const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon) + const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) + + const handleDirections = () => { + // No toast - empty origin slot is the visual prompt + startDirections(place) + } + const handleAddStop = () => { + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) + clearSelectedPlace() + } + const handleSave = () => { + if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return } + if (savedContact) setEditingContact(savedContact) + else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" }) + } + const closeCopy = useCallback(() => setCopyOpen(false), []) + const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null + + if (!expanded) { + return ( +
+ {draggable &&
} + {stopLetter &&
{stopLetter}
} + {(place.raw?.name || place.name) || "Unknown place"} + + {onRemove && } +
+ ) + } + + return ( +
+
+
+ {draggable &&
} + {stopLetter &&
{stopLetter}
} +
+ {(place.raw?.name || place.name) || "Unknown place"} +
+ {place.type && {place.type}} + {driveTime != null && <>{"\u00b7"}{formatDriveTime(driveTime)} drive} + {nearbyLabel && <>{"\u00b7"}Near {nearbyLabel}} +
+
+
+
+ {onToggleExpand && variant === "stop" && } + {onClose && } +
+
+ {address &&
{address}
} +
+ {place.lat.toFixed(6)}, {place.lon.toFixed(6)} + {"\u00b7"} + {elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"} +
+ + + {placeDetails === "loading" && } + {placeDetails && placeDetails !== "loading" && } +
+ {variant === "preview" && ( + <> + {stops.length < 2 && } + {existingStopIndex >= 0 ? ( + Stop {String.fromCharCode(65 + existingStopIndex)} + ) : ( + + )} + + )} + {variant === "stop" && onRemove && } + +
+ + {copyOpen && } +
+
+
+ ) +} + +export default PlaceCard diff --git a/src/components/StopList.jsx b/src/components/StopList.jsx index a370b09..80cb538 100644 --- a/src/components/StopList.jsx +++ b/src/components/StopList.jsx @@ -1,73 +1,117 @@ -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { useStore } from '../store' -import StopItem from './StopItem' -import GpsOriginItem from './GpsOriginItem' - -export default function StopList() { - const stops = useStore((s) => s.stops) - const reorderStops = useStore((s) => s.reorderStops) - const geoPermission = useStore((s) => s.geoPermission) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const pendingDestination = useStore((s) => s.pendingDestination) - - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const indexOffset = hasGpsOrigin ? 1 : 0 - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ) - - function handleDragEnd(event) { - const { active, over } = event - if (!over || active.id === over.id) return - - const oldIndex = stops.findIndex((s) => s.id === active.id) - const newIndex = stops.findIndex((s) => s.id === over.id) - reorderStops(arrayMove(stops, oldIndex, newIndex)) - } - - if (stops.length === 0 && !hasGpsOrigin) { - return ( -
- {pendingDestination - ? 'Search for a starting point above' - : geoPermission === 'denied' - ? 'Add a starting point and destination above' - : 'Search and add stops to build your route'} -
- ) - } - - return ( -
- {hasGpsOrigin && } - - s.id)} strategy={verticalListSortingStrategy}> - {stops.map((stop, i) => ( - - ))} - - -
- ) -} +import { useState } from 'react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { useStore } from '../store' +import { PlaceCard } from './PlaceCard' +import GpsOriginItem from './GpsOriginItem' + +// Wrapper to make PlaceCard sortable +function SortableStopCard({ stop, index, indexOffset }) { + const removeStop = useStore((s) => s.removeStop) + const [expanded, setExpanded] = useState(false) + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: stop.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + // Convert stop to place format for PlaceCard + const place = { + lat: stop.lat, + lon: stop.lon, + name: stop.name, + source: stop.source, + matchCode: stop.matchCode, + type: stop.type || null, + raw: stop.raw || null, + wikidata: stop.wikidata || null, + } + + return ( +
+ setExpanded(!expanded)} + onRemove={() => removeStop(stop.id)} + stopIndex={index + indexOffset} + draggable={true} + dragHandleProps={{ ...attributes, ...listeners }} + /> +
+ ) +} + +export default function StopList() { + const stops = useStore((s) => s.stops) + const reorderStops = useStore((s) => s.reorderStops) + const geoPermission = useStore((s) => s.geoPermission) + const gpsOrigin = useStore((s) => s.gpsOrigin) + const pendingDestination = useStore((s) => s.pendingDestination) + + const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' + const indexOffset = hasGpsOrigin ? 1 : 0 + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + function handleDragEnd(event) { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = stops.findIndex((s) => s.id === active.id) + const newIndex = stops.findIndex((s) => s.id === over.id) + reorderStops(arrayMove(stops, oldIndex, newIndex)) + } + + if (stops.length === 0 && !hasGpsOrigin) { + return ( +
+ {pendingDestination + ? 'Search for a starting point above' + : geoPermission === 'denied' + ? 'Add a starting point and destination above' + : 'Search and add stops to build your route'} +
+ ) + } + + return ( +
+ {hasGpsOrigin && } + + s.id)} strategy={verticalListSortingStrategy}> + {stops.map((stop, i) => ( + + ))} + + +
+ ) +} diff --git a/src/store.js b/src/store.js index 8e310d0..8ce6fcc 100644 --- a/src/store.js +++ b/src/store.js @@ -85,6 +85,9 @@ export const useStore = create((set, get) => ({ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) set({ selectedPlace: null }) } else { + // GPS denied, no stops: add destination, show empty origin slot + clearStops() + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) set({ pendingDestination: place, selectedPlace: null }) } }, @@ -105,6 +108,9 @@ export const useStore = create((set, get) => ({ if (override) { localStorage.setItem('navi-theme-override', override) } else { + // GPS denied, no stops: add destination, show empty origin slot + clearStops() + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) localStorage.removeItem('navi-theme-override') } }, @@ -119,3 +125,15 @@ export const useStore = create((set, get) => ({ setEditingContact: (c) => set({ editingContact: c }), clearEditingContact: () => set({ editingContact: null }), })) + +// ── Panel state selector ── +// IDLE | PREVIEW | ROUTING | PREVIEW_ROUTING | ROUTE_CALCULATED +export const usePanelState = () => { + return useStore((s) => { + if (s.route) return "ROUTE_CALCULATED" + if (s.selectedPlace && s.stops.length >= 1) return "PREVIEW_ROUTING" + if (s.selectedPlace) return "PREVIEW" + if (s.stops.length >= 1) return "ROUTING" + return "IDLE" + }) +}