mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
feat: add auth-state awareness and graceful degradation
- Add /api/auth/whoami endpoint check on app load - Store auth state in Zustand (authenticated, username, loaded) - Hide Contacts tab when unauthenticated - Gate fetchNearbyContacts calls on auth.authenticated - Replace Save button with Log in affordance when unauthenticated - Add Login/Logout buttons to panel header - Prevent any /api/contacts/* requests from firing when unauthenticated Public functionality (search, routing, place details) remains fully functional for unauthenticated users.
This commit is contained in:
parent
616d01623d
commit
0d4a807a05
29 changed files with 13091 additions and 317 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useStore } from './store'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { requestRoute } from './api'
|
||||
import { requestRoute, fetchAuthState } from './api'
|
||||
import { decodePolyline } from './utils/decode'
|
||||
import MapView from './components/MapView'
|
||||
import Panel from './components/Panel'
|
||||
|
|
@ -26,6 +26,12 @@ export default function App() {
|
|||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const setRouteError = useStore((s) => s.setRouteError)
|
||||
const clearRoute = useStore((s) => s.clearRoute)
|
||||
const setAuth = useStore((s) => s.setAuth)
|
||||
|
||||
// Initialize auth state on app load (single fetch, no polling)
|
||||
useEffect(() => {
|
||||
fetchAuthState().then(setAuth)
|
||||
}, [setAuth])
|
||||
|
||||
// Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
24
src/api.js
24
src/api.js
|
|
@ -286,3 +286,27 @@ export async function fetchLandclass(lat, lon, signal) {
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Auth API ──
|
||||
|
||||
/**
|
||||
* Check authentication state via whoami endpoint.
|
||||
* Uses redirect: manual to detect auth without triggering navigation.
|
||||
* @returns {Promise<{authenticated: boolean, username: string|null}>}
|
||||
*/
|
||||
export async function fetchAuthState() {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/whoami', { redirect: 'manual' })
|
||||
// Redirect response means unauthenticated (Authentik SSO flow)
|
||||
if (resp.type === 'opaqueredirect' || resp.status === 302) {
|
||||
return { authenticated: false, username: null }
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return { authenticated: false, username: null }
|
||||
}
|
||||
return resp.json()
|
||||
} catch {
|
||||
return { authenticated: false, username: null }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ const VALHALLA_HEIGHT_URL = '/valhalla/height'
|
|||
* @param {AbortSignal} signal
|
||||
* @returns {Promise<{query, results, count}>}
|
||||
*/
|
||||
export async function searchGeocode(query, limit = 6, signal) {
|
||||
export async function searchGeocode(query, limit = 6, signal, viewport = null) {
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) })
|
||||
if (viewport?.lat != null) params.set('lat', String(viewport.lat))
|
||||
if (viewport?.lon != null) params.set('lon', String(viewport.lon))
|
||||
if (viewport?.zoom != null) params.set('zoom', String(Math.round(viewport.zoom)))
|
||||
const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 })
|
||||
if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
|
||||
return resp.json()
|
||||
|
|
@ -191,6 +194,19 @@ export async function fetchPlaceDetails(osmType, osmId, signal) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function fetchPlaceByWikidata(wikidataId, signal) {
|
||||
try {
|
||||
const resp = await fetch(`/api/place/wikidata/${wikidataId}`, {
|
||||
signal,
|
||||
headers: { "Accept": "application/json" },
|
||||
})
|
||||
if (!resp.ok) return null
|
||||
return resp.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contacts API ──
|
||||
|
||||
export async function fetchContacts(signal) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, MapPin, User, Phone, Radio } from 'lucide-react'
|
||||
import { Plus, MapPin, User, Phone, Radio, LogIn } from 'lucide-react'
|
||||
import { useStore } from '../store'
|
||||
import { fetchContacts } from '../api'
|
||||
|
||||
|
|
@ -9,30 +9,40 @@ export default function ContactList() {
|
|||
const setContacts = useStore((s) => s.setContacts)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||
const auth = useStore((s) => s.auth)
|
||||
|
||||
const [filter, setFilter] = useState('')
|
||||
const [authFailed, setAuthFailed] = useState(false)
|
||||
|
||||
const loadContacts = useCallback(async () => {
|
||||
// Skip fetch entirely if not authenticated
|
||||
if (!auth.authenticated) return
|
||||
const data = await fetchContacts()
|
||||
if (data?.auth === false) {
|
||||
setAuthFailed(true)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
setContacts(data)
|
||||
setAuthFailed(false)
|
||||
}
|
||||
}, [setContacts])
|
||||
}, [setContacts, auth.authenticated])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contactsLoaded) loadContacts()
|
||||
}, [contactsLoaded, loadContacts])
|
||||
if (auth.loaded && auth.authenticated && !contactsLoaded) {
|
||||
loadContacts()
|
||||
}
|
||||
}, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts])
|
||||
|
||||
if (authFailed) {
|
||||
// Show login prompt if not authenticated
|
||||
if (auth.loaded && !auth.authenticated) {
|
||||
return (
|
||||
<div className="mt-4 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Sign in to use contacts</p>
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Sign in to save and sync your contacts
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { window.location.href = '/api/auth/whoami' }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
|
||||
>
|
||||
<LogIn size={12} />
|
||||
Log in
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
1403
src/components/MapView.jsx.bak.boundary
Normal file
1403
src/components/MapView.jsx.bak.boundary
Normal file
File diff suppressed because it is too large
Load diff
1328
src/components/MapView.jsx.bak.labelclick
Normal file
1328
src/components/MapView.jsx.bak.labelclick
Normal file
File diff suppressed because it is too large
Load diff
1315
src/components/MapView.jsx.bak.poihover
Normal file
1315
src/components/MapView.jsx.bak.poihover
Normal file
File diff suppressed because it is too large
Load diff
1514
src/components/MapView.jsx.bak.regressions
Normal file
1514
src/components/MapView.jsx.bak.regressions
Normal file
File diff suppressed because it is too large
Load diff
1256
src/components/MapView.jsx.bak.twoclick
Normal file
1256
src/components/MapView.jsx.bak.twoclick
Normal file
File diff suppressed because it is too large
Load diff
1514
src/components/MapView.jsx.bak.uxfix2
Normal file
1514
src/components/MapView.jsx.bak.uxfix2
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,284 +1,315 @@
|
|||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
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 pendingDestination = useStore((s) => s.pendingDestination)
|
||||
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const mode = useStore((s) => s.mode)
|
||||
const route = useStore((s) => s.route)
|
||||
const routeLoading = useStore((s) => s.routeLoading)
|
||||
const routeError = useStore((s) => s.routeError)
|
||||
const setStops = useStore((s) => s.setStops)
|
||||
const setRoute = useStore((s) => s.setRoute)
|
||||
const setRouteError = useStore((s) => s.setRouteError)
|
||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const sheetState = useStore((s) => s.sheetState)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const themeOverride = useStore((s) => s.themeOverride)
|
||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
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)
|
||||
const dragStartY = useRef(0)
|
||||
const dragStartState = useRef('half')
|
||||
|
||||
const showContacts = hasFeature('has_contacts')
|
||||
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Theme toggle
|
||||
const toggleTheme = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
}
|
||||
|
||||
// Optimize stops
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||
|
||||
const handleOptimize = useCallback(async () => {
|
||||
if (effectiveCount < 3 || optimizing) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const { userLocation } = useStore.getState()
|
||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (hasGpsOrigin && userLocation) {
|
||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
if (wpOrder && wpOrder.length === stops.length) {
|
||||
const reordered = wpOrder.map((wp) => {
|
||||
let closest = stops[0]
|
||||
let minDist = Infinity
|
||||
for (const s of stops) {
|
||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
closest = s
|
||||
}
|
||||
}
|
||||
return closest
|
||||
})
|
||||
const seen = new Set()
|
||||
const unique = reordered.filter((s) => {
|
||||
if (seen.has(s.id)) return false
|
||||
seen.add(s.id)
|
||||
return true
|
||||
})
|
||||
if (unique.length === stops.length) {
|
||||
setStops(unique)
|
||||
}
|
||||
}
|
||||
setRoute(data.trip)
|
||||
}
|
||||
} catch (e) {
|
||||
setRouteError(e.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||
|
||||
// Mobile sheet drag handling
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY
|
||||
dragStartState.current = sheetState
|
||||
}, [sheetState])
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||
if (Math.abs(deltaY) < 30) return
|
||||
if (deltaY < 0) {
|
||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('full')
|
||||
} else {
|
||||
if (dragStartState.current === 'full') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
const showOptimize = effectiveCount >= 3
|
||||
|
||||
// Determine what to show based on panel state
|
||||
const showPreviewCard = panelState.startsWith('PREVIEW')
|
||||
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
|
||||
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
|
||||
const showEmptyState = panelState === 'IDLE' && !pendingDestination
|
||||
|
||||
// Routes tab content - now state-driven
|
||||
const routesContent = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
{/* Preview card when place is selected */}
|
||||
{showPreviewCard && selectedPlace && (
|
||||
<div className="mt-3">
|
||||
<PlaceCard
|
||||
place={selectedPlace}
|
||||
variant="preview"
|
||||
expanded={true}
|
||||
onClose={clearSelectedPlace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route section with stops */}
|
||||
{showRouteSection && (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<StopList />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ModeSelector />
|
||||
{showOptimize && (
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing || routeLoading}
|
||||
className="navi-btn-secondary w-full"
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Maneuvers when route is calculated */}
|
||||
{showManeuvers && (route || routeLoading || routeError) && (
|
||||
<div className="mt-3">
|
||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmptyState && (
|
||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Search or tap the map to explore</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showContacts && (
|
||||
<div className="navi-tab-bar mb-3">
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('routes')}
|
||||
>
|
||||
Routes
|
||||
</button>
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('contacts')}
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||
</>
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
width: '400px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: bottom sheet
|
||||
const sheetHeights = {
|
||||
collapsed: 'h-12',
|
||||
half: 'h-[45vh]',
|
||||
full: 'h-[85vh]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex justify-center py-2 cursor-grab"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => {
|
||||
if (sheetState === 'collapsed') setSheetState('half')
|
||||
else if (sheetState === 'half') setSheetState('full')
|
||||
else setSheetState('half')
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{sheetState !== 'collapsed' && (
|
||||
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon, LogIn, LogOut } from 'lucide-react'
|
||||
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 pendingDestination = useStore((s) => s.pendingDestination)
|
||||
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const mode = useStore((s) => s.mode)
|
||||
const route = useStore((s) => s.route)
|
||||
const routeLoading = useStore((s) => s.routeLoading)
|
||||
const routeError = useStore((s) => s.routeError)
|
||||
const setStops = useStore((s) => s.setStops)
|
||||
const setRoute = useStore((s) => s.setRoute)
|
||||
const setRouteError = useStore((s) => s.setRouteError)
|
||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const sheetState = useStore((s) => s.sheetState)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const themeOverride = useStore((s) => s.themeOverride)
|
||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
const activeTab = useStore((s) => s.activeTab)
|
||||
const auth = useStore((s) => s.auth)
|
||||
const setActiveTab = useStore((s) => s.setActiveTab)
|
||||
|
||||
const panelState = usePanelState()
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const sheetRef = useRef(null)
|
||||
const dragStartY = useRef(0)
|
||||
const dragStartState = useRef('half')
|
||||
|
||||
// Show contacts tab only if feature enabled AND user is authenticated
|
||||
const showContacts = hasFeature('has_contacts') && auth.authenticated
|
||||
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Theme toggle
|
||||
const toggleTheme = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
}
|
||||
|
||||
// Auth handlers
|
||||
const handleLogin = () => { window.location.href = '/api/auth/whoami' }
|
||||
const handleLogout = () => { window.location.href = '/outpost.goauthentik.io/sign_out' }
|
||||
|
||||
// Optimize stops
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||
|
||||
const handleOptimize = useCallback(async () => {
|
||||
if (effectiveCount < 3 || optimizing) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const { userLocation } = useStore.getState()
|
||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (hasGpsOrigin && userLocation) {
|
||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
if (wpOrder && wpOrder.length === stops.length) {
|
||||
const reordered = wpOrder.map((wp) => {
|
||||
let closest = stops[0]
|
||||
let minDist = Infinity
|
||||
for (const s of stops) {
|
||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
closest = s
|
||||
}
|
||||
}
|
||||
return closest
|
||||
})
|
||||
const seen = new Set()
|
||||
const unique = reordered.filter((s) => {
|
||||
if (seen.has(s.id)) return false
|
||||
seen.add(s.id)
|
||||
return true
|
||||
})
|
||||
if (unique.length === stops.length) {
|
||||
setStops(unique)
|
||||
}
|
||||
}
|
||||
setRoute(data.trip)
|
||||
}
|
||||
} catch (e) {
|
||||
setRouteError(e.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||
|
||||
// Mobile sheet drag handling
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY
|
||||
dragStartState.current = sheetState
|
||||
}, [sheetState])
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||
if (Math.abs(deltaY) < 30) return
|
||||
if (deltaY < 0) {
|
||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('full')
|
||||
} else {
|
||||
if (dragStartState.current === 'full') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
const showOptimize = effectiveCount >= 3
|
||||
|
||||
// Determine what to show based on panel state
|
||||
const showPreviewCard = panelState.startsWith('PREVIEW')
|
||||
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
|
||||
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
|
||||
const showEmptyState = panelState === 'IDLE' && !pendingDestination
|
||||
|
||||
// Routes tab content - now state-driven
|
||||
const routesContent = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
{/* Preview card when place is selected */}
|
||||
{showPreviewCard && selectedPlace && (
|
||||
<div className="mt-3">
|
||||
<PlaceCard
|
||||
place={selectedPlace}
|
||||
variant="preview"
|
||||
expanded={true}
|
||||
onClose={clearSelectedPlace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route section with stops */}
|
||||
{showRouteSection && (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<StopList />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ModeSelector />
|
||||
{showOptimize && (
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing || routeLoading}
|
||||
className="navi-btn-secondary w-full"
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Maneuvers when route is calculated */}
|
||||
{showManeuvers && (route || routeLoading || routeError) && (
|
||||
<div className="mt-3">
|
||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmptyState && (
|
||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Search or tap the map to explore</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showContacts && (
|
||||
<div className="navi-tab-bar mb-3">
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('routes')}
|
||||
>
|
||||
Routes
|
||||
</button>
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('contacts')}
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||
</>
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
{auth.loaded && (
|
||||
auth.authenticated ? (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title={`Logged in as ${auth.username}. Click to log out.`}
|
||||
>
|
||||
<span className="hidden sm:inline">{auth.username}</span>
|
||||
<LogOut size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
title="Log in"
|
||||
>
|
||||
<LogIn size={14} />
|
||||
<span>Log in</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
width: '400px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: bottom sheet
|
||||
const sheetHeights = {
|
||||
collapsed: 'h-12',
|
||||
half: 'h-[45vh]',
|
||||
full: 'h-[85vh]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex justify-center py-2 cursor-grab"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => {
|
||||
if (sheetState === 'collapsed') setSheetState('half')
|
||||
else if (sheetState === 'half') setSheetState('full')
|
||||
else setSheetState('half')
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{sheetState !== 'collapsed' && (
|
||||
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
283
src/components/Panel.jsx.bak.regressions
Normal file
283
src/components/Panel.jsx.bak.regressions
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
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)
|
||||
const routeLoading = useStore((s) => s.routeLoading)
|
||||
const routeError = useStore((s) => s.routeError)
|
||||
const setStops = useStore((s) => s.setStops)
|
||||
const setRoute = useStore((s) => s.setRoute)
|
||||
const setRouteError = useStore((s) => s.setRouteError)
|
||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const sheetState = useStore((s) => s.sheetState)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const themeOverride = useStore((s) => s.themeOverride)
|
||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
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)
|
||||
const dragStartY = useRef(0)
|
||||
const dragStartState = useRef('half')
|
||||
|
||||
const showContacts = hasFeature('has_contacts')
|
||||
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Theme toggle
|
||||
const toggleTheme = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
}
|
||||
|
||||
// Optimize stops
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||
|
||||
const handleOptimize = useCallback(async () => {
|
||||
if (effectiveCount < 3 || optimizing) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const { userLocation } = useStore.getState()
|
||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (hasGpsOrigin && userLocation) {
|
||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
if (wpOrder && wpOrder.length === stops.length) {
|
||||
const reordered = wpOrder.map((wp) => {
|
||||
let closest = stops[0]
|
||||
let minDist = Infinity
|
||||
for (const s of stops) {
|
||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
closest = s
|
||||
}
|
||||
}
|
||||
return closest
|
||||
})
|
||||
const seen = new Set()
|
||||
const unique = reordered.filter((s) => {
|
||||
if (seen.has(s.id)) return false
|
||||
seen.add(s.id)
|
||||
return true
|
||||
})
|
||||
if (unique.length === stops.length) {
|
||||
setStops(unique)
|
||||
}
|
||||
}
|
||||
setRoute(data.trip)
|
||||
}
|
||||
} catch (e) {
|
||||
setRouteError(e.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||
|
||||
// Mobile sheet drag handling
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY
|
||||
dragStartState.current = sheetState
|
||||
}, [sheetState])
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||
if (Math.abs(deltaY) < 30) return
|
||||
if (deltaY < 0) {
|
||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('full')
|
||||
} else {
|
||||
if (dragStartState.current === 'full') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
{/* Preview card when place is selected */}
|
||||
{showPreviewCard && selectedPlace && (
|
||||
<div className="mt-3">
|
||||
<PlaceCard
|
||||
place={selectedPlace}
|
||||
variant="preview"
|
||||
expanded={true}
|
||||
onClose={clearSelectedPlace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route section with stops */}
|
||||
{showRouteSection && (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<StopList />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ModeSelector />
|
||||
{showOptimize && (
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing || routeLoading}
|
||||
className="navi-btn-secondary w-full"
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Maneuvers when route is calculated */}
|
||||
{showManeuvers && (route || routeLoading || routeError) && (
|
||||
<div className="mt-3">
|
||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmptyState && (
|
||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Search or tap the map to explore</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showContacts && (
|
||||
<div className="navi-tab-bar mb-3">
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('routes')}
|
||||
>
|
||||
Routes
|
||||
</button>
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('contacts')}
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||
</>
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
width: '400px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: bottom sheet
|
||||
const sheetHeights = {
|
||||
collapsed: 'h-12',
|
||||
half: 'h-[45vh]',
|
||||
full: 'h-[85vh]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex justify-center py-2 cursor-grab"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => {
|
||||
if (sheetState === 'collapsed') setSheetState('half')
|
||||
else if (sheetState === 'half') setSheetState('full')
|
||||
else setSheetState('half')
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{sheetState !== 'collapsed' && (
|
||||
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
283
src/components/Panel.jsx.bak.uxfix
Normal file
283
src/components/Panel.jsx.bak.uxfix
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
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)
|
||||
const routeLoading = useStore((s) => s.routeLoading)
|
||||
const routeError = useStore((s) => s.routeError)
|
||||
const setStops = useStore((s) => s.setStops)
|
||||
const setRoute = useStore((s) => s.setRoute)
|
||||
const setRouteError = useStore((s) => s.setRouteError)
|
||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const sheetState = useStore((s) => s.sheetState)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const themeOverride = useStore((s) => s.themeOverride)
|
||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
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)
|
||||
const dragStartY = useRef(0)
|
||||
const dragStartState = useRef('half')
|
||||
|
||||
const showContacts = hasFeature('has_contacts')
|
||||
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Theme toggle
|
||||
const toggleTheme = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
}
|
||||
|
||||
// Optimize stops
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||
|
||||
const handleOptimize = useCallback(async () => {
|
||||
if (effectiveCount < 3 || optimizing) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const { userLocation } = useStore.getState()
|
||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (hasGpsOrigin && userLocation) {
|
||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
if (wpOrder && wpOrder.length === stops.length) {
|
||||
const reordered = wpOrder.map((wp) => {
|
||||
let closest = stops[0]
|
||||
let minDist = Infinity
|
||||
for (const s of stops) {
|
||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
closest = s
|
||||
}
|
||||
}
|
||||
return closest
|
||||
})
|
||||
const seen = new Set()
|
||||
const unique = reordered.filter((s) => {
|
||||
if (seen.has(s.id)) return false
|
||||
seen.add(s.id)
|
||||
return true
|
||||
})
|
||||
if (unique.length === stops.length) {
|
||||
setStops(unique)
|
||||
}
|
||||
}
|
||||
setRoute(data.trip)
|
||||
}
|
||||
} catch (e) {
|
||||
setRouteError(e.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||
|
||||
// Mobile sheet drag handling
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY
|
||||
dragStartState.current = sheetState
|
||||
}, [sheetState])
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||
if (Math.abs(deltaY) < 30) return
|
||||
if (deltaY < 0) {
|
||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('full')
|
||||
} else {
|
||||
if (dragStartState.current === 'full') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
{/* Preview card when place is selected */}
|
||||
{showPreviewCard && selectedPlace && (
|
||||
<div className="mt-3">
|
||||
<PlaceCard
|
||||
place={selectedPlace}
|
||||
variant="preview"
|
||||
expanded={true}
|
||||
onClose={clearSelectedPlace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route section with stops */}
|
||||
{showRouteSection && (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<StopList />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ModeSelector />
|
||||
{showOptimize && (
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing || routeLoading}
|
||||
className="navi-btn-secondary w-full"
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Maneuvers when route is calculated */}
|
||||
{showManeuvers && (route || routeLoading || routeError) && (
|
||||
<div className="mt-3">
|
||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmptyState && (
|
||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Search or tap the map to explore</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showContacts && (
|
||||
<div className="navi-tab-bar mb-3">
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('routes')}
|
||||
>
|
||||
Routes
|
||||
</button>
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('contacts')}
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||
</>
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
width: '360px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: bottom sheet
|
||||
const sheetHeights = {
|
||||
collapsed: 'h-12',
|
||||
half: 'h-[45vh]',
|
||||
full: 'h-[85vh]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex justify-center py-2 cursor-grab"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => {
|
||||
if (sheetState === 'collapsed') setSheetState('half')
|
||||
else if (sheetState === 'half') setSheetState('full')
|
||||
else setSheetState('half')
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{sheetState !== 'collapsed' && (
|
||||
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
283
src/components/Panel.jsx.bak.uxfix2
Normal file
283
src/components/Panel.jsx.bak.uxfix2
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
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)
|
||||
const routeLoading = useStore((s) => s.routeLoading)
|
||||
const routeError = useStore((s) => s.routeError)
|
||||
const setStops = useStore((s) => s.setStops)
|
||||
const setRoute = useStore((s) => s.setRoute)
|
||||
const setRouteError = useStore((s) => s.setRouteError)
|
||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const sheetState = useStore((s) => s.sheetState)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const themeOverride = useStore((s) => s.themeOverride)
|
||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
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)
|
||||
const dragStartY = useRef(0)
|
||||
const dragStartState = useRef('half')
|
||||
|
||||
const showContacts = hasFeature('has_contacts')
|
||||
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Theme toggle
|
||||
const toggleTheme = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
}
|
||||
|
||||
// Optimize stops
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||
|
||||
const handleOptimize = useCallback(async () => {
|
||||
if (effectiveCount < 3 || optimizing) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const { userLocation } = useStore.getState()
|
||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (hasGpsOrigin && userLocation) {
|
||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
if (wpOrder && wpOrder.length === stops.length) {
|
||||
const reordered = wpOrder.map((wp) => {
|
||||
let closest = stops[0]
|
||||
let minDist = Infinity
|
||||
for (const s of stops) {
|
||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
closest = s
|
||||
}
|
||||
}
|
||||
return closest
|
||||
})
|
||||
const seen = new Set()
|
||||
const unique = reordered.filter((s) => {
|
||||
if (seen.has(s.id)) return false
|
||||
seen.add(s.id)
|
||||
return true
|
||||
})
|
||||
if (unique.length === stops.length) {
|
||||
setStops(unique)
|
||||
}
|
||||
}
|
||||
setRoute(data.trip)
|
||||
}
|
||||
} catch (e) {
|
||||
setRouteError(e.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||
|
||||
// Mobile sheet drag handling
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY
|
||||
dragStartState.current = sheetState
|
||||
}, [sheetState])
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||
if (Math.abs(deltaY) < 30) return
|
||||
if (deltaY < 0) {
|
||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('full')
|
||||
} else {
|
||||
if (dragStartState.current === 'full') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
{/* Preview card when place is selected */}
|
||||
{showPreviewCard && selectedPlace && (
|
||||
<div className="mt-3">
|
||||
<PlaceCard
|
||||
place={selectedPlace}
|
||||
variant="preview"
|
||||
expanded={true}
|
||||
onClose={clearSelectedPlace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route section with stops */}
|
||||
{showRouteSection && (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<StopList />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ModeSelector />
|
||||
{showOptimize && (
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing || routeLoading}
|
||||
className="navi-btn-secondary w-full"
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Maneuvers when route is calculated */}
|
||||
{showManeuvers && (route || routeLoading || routeError) && (
|
||||
<div className="mt-3">
|
||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmptyState && (
|
||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Search or tap the map to explore</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showContacts && (
|
||||
<div className="navi-tab-bar mb-3">
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('routes')}
|
||||
>
|
||||
Routes
|
||||
</button>
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('contacts')}
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||
</>
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
width: '360px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: bottom sheet
|
||||
const sheetHeights = {
|
||||
collapsed: 'h-12',
|
||||
half: 'h-[45vh]',
|
||||
full: 'h-[85vh]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex justify-center py-2 cursor-grab"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => {
|
||||
if (sheetState === 'collapsed') setSheetState('half')
|
||||
else if (sheetState === 'half') setSheetState('full')
|
||||
else setSheetState('half')
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{sheetState !== 'collapsed' && (
|
||||
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
283
src/components/Panel.jsx.bak.viewport
Normal file
283
src/components/Panel.jsx.bak.viewport
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
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)
|
||||
const routeLoading = useStore((s) => s.routeLoading)
|
||||
const routeError = useStore((s) => s.routeError)
|
||||
const setStops = useStore((s) => s.setStops)
|
||||
const setRoute = useStore((s) => s.setRoute)
|
||||
const setRouteError = useStore((s) => s.setRouteError)
|
||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const sheetState = useStore((s) => s.sheetState)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const themeOverride = useStore((s) => s.themeOverride)
|
||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
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)
|
||||
const dragStartY = useRef(0)
|
||||
const dragStartState = useRef('half')
|
||||
|
||||
const showContacts = hasFeature('has_contacts')
|
||||
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Theme toggle
|
||||
const toggleTheme = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
}
|
||||
|
||||
// Optimize stops
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||
|
||||
const handleOptimize = useCallback(async () => {
|
||||
if (effectiveCount < 3 || optimizing) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const { userLocation } = useStore.getState()
|
||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (hasGpsOrigin && userLocation) {
|
||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
if (wpOrder && wpOrder.length === stops.length) {
|
||||
const reordered = wpOrder.map((wp) => {
|
||||
let closest = stops[0]
|
||||
let minDist = Infinity
|
||||
for (const s of stops) {
|
||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
closest = s
|
||||
}
|
||||
}
|
||||
return closest
|
||||
})
|
||||
const seen = new Set()
|
||||
const unique = reordered.filter((s) => {
|
||||
if (seen.has(s.id)) return false
|
||||
seen.add(s.id)
|
||||
return true
|
||||
})
|
||||
if (unique.length === stops.length) {
|
||||
setStops(unique)
|
||||
}
|
||||
}
|
||||
setRoute(data.trip)
|
||||
}
|
||||
} catch (e) {
|
||||
setRouteError(e.message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||
|
||||
// Mobile sheet drag handling
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY
|
||||
dragStartState.current = sheetState
|
||||
}, [sheetState])
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||
if (Math.abs(deltaY) < 30) return
|
||||
if (deltaY < 0) {
|
||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('full')
|
||||
} else {
|
||||
if (dragStartState.current === 'full') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
const showOptimize = effectiveCount >= 3
|
||||
|
||||
// Determine what to show based on panel state
|
||||
const showPreviewCard = panelState.startsWith('PREVIEW')
|
||||
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState)
|
||||
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
|
||||
const showEmptyState = panelState === 'IDLE'
|
||||
|
||||
// Routes tab content - now state-driven
|
||||
const routesContent = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
{/* Preview card when place is selected */}
|
||||
{showPreviewCard && selectedPlace && (
|
||||
<div className="mt-3">
|
||||
<PlaceCard
|
||||
place={selectedPlace}
|
||||
variant="preview"
|
||||
expanded={true}
|
||||
onClose={clearSelectedPlace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route section with stops */}
|
||||
{showRouteSection && (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<StopList />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ModeSelector />
|
||||
{showOptimize && (
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing || routeLoading}
|
||||
className="navi-btn-secondary w-full"
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Maneuvers when route is calculated */}
|
||||
{showManeuvers && (route || routeLoading || routeError) && (
|
||||
<div className="mt-3">
|
||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmptyState && (
|
||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Search or tap the map to explore</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showContacts && (
|
||||
<div className="navi-tab-bar mb-3">
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('routes')}
|
||||
>
|
||||
Routes
|
||||
</button>
|
||||
<button
|
||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('contacts')}
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||
</>
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
width: '400px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: bottom sheet
|
||||
const sheetHeights = {
|
||||
collapsed: 'h-12',
|
||||
half: 'h-[45vh]',
|
||||
full: 'h-[85vh]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex justify-center py-2 cursor-grab"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => {
|
||||
if (sheetState === 'collapsed') setSheetState('half')
|
||||
else if (sheetState === 'half') setSheetState('full')
|
||||
else setSheetState('half')
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{sheetState !== 'collapsed' && (
|
||||
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState, useRef, useCallback } from "react"
|
||||
import {
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
|
||||
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
|
||||
} from "lucide-react"
|
||||
import OpeningHours from "opening_hours"
|
||||
|
|
@ -245,7 +245,15 @@ function CopyPopover({ address, place, onClose }) {
|
|||
}
|
||||
|
||||
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 contacts = useStore((s) => s.contacts)
|
||||
const userLocation = useStore((s) => s.userLocation)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
const addStop = useStore((s) => s.addStop)
|
||||
const startDirections = useStore((s) => s.startDirections)
|
||||
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
const auth = useStore((s) => s.auth)
|
||||
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
||||
const [placeDetails, setPlaceDetails] = useState(null)
|
||||
const [driveTime, setDriveTime] = useState(null)
|
||||
|
|
@ -421,7 +429,11 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl
|
|||
</>
|
||||
)}
|
||||
{variant === "stop" && onRemove && <button onClick={onRemove} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><X size={13} />Remove</button>}
|
||||
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
|
||||
{auth.authenticated ? (
|
||||
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
|
||||
) : (
|
||||
<button onClick={() => { window.location.href = "/api/auth/whoami" }} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs" style={{ background: "var(--accent-muted)", color: "var(--accent)", border: "1px solid var(--border)" }} title="Log in to save places"><LogIn size={12} /><span>Save</span></button>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
|
||||
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
|
||||
|
|
|
|||
434
src/components/PlaceCard.jsx.bak.uxfix
Normal file
434
src/components/PlaceCard.jsx.bak.uxfix
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import { useEffect, useState, useRef, useCallback } from "react"
|
||||
import {
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
||||
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
|
||||
} from "lucide-react"
|
||||
import OpeningHours from "opening_hours"
|
||||
import toast from "react-hot-toast"
|
||||
import { useStore } from "../store"
|
||||
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
|
||||
import { hasFeature } from "../config"
|
||||
import { buildAddress } from "../utils/place"
|
||||
|
||||
const M_TO_FT = 3.28084
|
||||
|
||||
function formatDriveTime(seconds) {
|
||||
const mins = Math.round(seconds / 60)
|
||||
if (mins < 2) return "< 2 min"
|
||||
if (mins < 120) return `${mins} min`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 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 (
|
||||
<div className="text-xs" style={{ paddingTop: first ? 0 : "0.5rem", borderTop: first ? "none" : "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-1.5 mb-1.5" style={{ color: "var(--text-tertiary)" }}>
|
||||
<Icon size={12} />
|
||||
<span className="uppercase text-[10px] font-medium tracking-wide">{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HoursDisplay({ hoursStr, first }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const parsed = parseHours(hoursStr)
|
||||
if (!parsed) return null
|
||||
const { isOpen, todayStr, week } = parsed
|
||||
return (
|
||||
<DetailSection label="Hours" icon={Clock} first={first}>
|
||||
<button onClick={() => setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
|
||||
<span style={{ color: isOpen ? "var(--success)" : "var(--text-tertiary)" }}>{todayStr}</span>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 flex flex-col gap-0.5 text-[11px]">
|
||||
{week.map((w) => (
|
||||
<div key={w.day} className="flex justify-between" style={{ color: w.isTodayRow ? "var(--text-primary)" : "var(--text-secondary)", fontWeight: w.isTodayRow ? 500 : 400 }}>
|
||||
<span>{w.day}</span>
|
||||
<span>{w.hours}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
)
|
||||
}
|
||||
|
||||
function LandclassSection({ data }) {
|
||||
if (!data || !data.summary) return null
|
||||
return (
|
||||
<div className="mt-2 flex items-start gap-2 text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||
<Trees size={14} style={{ color: "var(--text-tertiary)", flexShrink: 0, marginTop: 1 }} />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{data.summary}</span>
|
||||
{data.unit_name && <span style={{ color: "var(--text-tertiary)" }}>{data.unit_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivateLandIndicator({ data }) {
|
||||
if (!data || data.gap_status !== "4") return null
|
||||
return (
|
||||
<div className="mt-2 px-2 py-1.5 rounded text-xs" style={{ background: "var(--warning-muted)", color: "var(--warning)", border: "1px solid var(--warning)" }}>
|
||||
Private land — permission required
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnrichmentSkeleton() {
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3 animate-pulse">
|
||||
<div className="h-3 rounded w-1/3" style={{ background: "var(--bg-inset)" }} />
|
||||
<div className="h-3 rounded w-2/3" style={{ background: "var(--bg-inset)" }} />
|
||||
<div className="h-3 rounded w-1/2" style={{ background: "var(--bg-inset)" }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-3 flex flex-col gap-2.5">
|
||||
{hasAbout && (
|
||||
<DetailSection label="About" icon={Info} first={idx++ === 0}>
|
||||
<span className="category-badge">{category}</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
|
||||
{hasContact && (
|
||||
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{et.phone && <a href={"tel:" + et.phone} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Phone size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{formatPhone(et.phone)}</a>}
|
||||
{et.website && <a href={et.website.startsWith("http") ? et.website : "https://" + et.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs truncate" style={{ color: "var(--accent)" }}><Globe size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}</a>}
|
||||
{et.email && <a href={"mailto:" + et.email} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Mail size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.email}</a>}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
{hasDetails && (
|
||||
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")}</span>}
|
||||
{et.operator && <span>Operated by {et.operator}</span>}
|
||||
{et.fee && <span>{et.fee === "no" ? "Free" : "Fee: " + et.fee}</span>}
|
||||
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
|
||||
{et.takeaway === "yes" && <span>Takeaway available</span>}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
{hasLinks && (
|
||||
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{et.wikipedia && wikiUrl(et.wikipedia) && <a href={wikiUrl(et.wikipedia)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}><BookOpen size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{wikiLabel(et.wikipedia)}</a>}
|
||||
{et.wikidata && <a href={"https://www.wikidata.org/wiki/" + et.wikidata} target="_blank" rel="noopener noreferrer" className="text-[11px]" style={{ color: "var(--text-tertiary)", textDecoration: "underline" }}>View on Wikidata</a>}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div ref={ref} className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]" style={{ background: "var(--bg-overlay)", border: "1px solid var(--border)", boxShadow: "var(--shadow-lg)" }}>
|
||||
<button onClick={address ? copyAddress : undefined} disabled={!address} className="w-full text-left px-3 py-1.5 text-xs" style={{ color: address ? "var(--text-primary)" : "var(--text-tertiary)", cursor: address ? "pointer" : "not-allowed" }}>Address</button>
|
||||
<button onClick={copyCoords} className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80" style={{ color: "var(--text-primary)" }}>Coordinates</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
startDirections(place)
|
||||
if (geoPermission !== "granted" && stops.length === 0) toast("Set a starting point to get directions", { icon: "\u{1F4CD}" })
|
||||
}
|
||||
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 (
|
||||
<div className="navi-place-card navi-place-card-collapsed flex items-center gap-2 p-2 rounded-lg cursor-pointer" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }} onClick={onToggleExpand}>
|
||||
{draggable && <div {...dragHandleProps} className="cursor-grab" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
|
||||
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
|
||||
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
|
||||
<ChevronDown size={14} style={{ color: "var(--text-tertiary)" }} />
|
||||
{onRemove && <button onClick={(e) => { e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="navi-place-card navi-place-card-expanded flex flex-col rounded-lg p-3" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
{draggable && <div {...dragHandleProps} className="cursor-grab mt-0.5" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
|
||||
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
|
||||
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
|
||||
{place.type && <span className="capitalize">{place.type}</span>}
|
||||
{driveTime != null && <><span>{"\u00b7"}</span><span>{formatDriveTime(driveTime)} drive</span></>}
|
||||
{nearbyLabel && <><span>{"\u00b7"}</span><span>Near {nearbyLabel}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onToggleExpand && variant === "stop" && <button onClick={onToggleExpand} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><ChevronUp size={14} /></button>}
|
||||
{onClose && <button onClick={onClose} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
|
||||
</div>
|
||||
</div>
|
||||
{address && <div className="text-xs mb-2" style={{ color: "var(--text-secondary)" }}>{address}</div>}
|
||||
<div className="flex items-center text-[11px] mb-2" style={{ color: "var(--text-tertiary)" }}>
|
||||
<span>{place.lat.toFixed(6)}, {place.lon.toFixed(6)}</span>
|
||||
<span className="mx-2">{"\u00b7"}</span>
|
||||
<span>{elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}</span>
|
||||
</div>
|
||||
<LandclassSection data={landclass} />
|
||||
<PrivateLandIndicator data={landclass} />
|
||||
{placeDetails === "loading" && <EnrichmentSkeleton />}
|
||||
{placeDetails && placeDetails !== "loading" && <EnrichmentSections details={placeDetails} />}
|
||||
<div className="mt-3 pt-3 flex gap-2" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
{variant === "preview" && (
|
||||
<>
|
||||
<button onClick={handleDirections} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}><Navigation size={13} />Directions</button>
|
||||
{existingStopIndex >= 0 ? (
|
||||
<span className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent-muted)", color: "var(--accent)" }}>Stop {String.fromCharCode(65 + existingStopIndex)}</span>
|
||||
) : (
|
||||
<button onClick={handleAddStop} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><Plus size={13} />Add stop</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{variant === "stop" && onRemove && <button onClick={onRemove} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><X size={13} />Remove</button>}
|
||||
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
|
||||
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlaceCard
|
||||
434
src/components/PlaceCard.jsx.bak.uxfix2
Normal file
434
src/components/PlaceCard.jsx.bak.uxfix2
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import { useEffect, useState, useRef, useCallback } from "react"
|
||||
import {
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
||||
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
|
||||
} from "lucide-react"
|
||||
import OpeningHours from "opening_hours"
|
||||
import toast from "react-hot-toast"
|
||||
import { useStore } from "../store"
|
||||
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
|
||||
import { hasFeature } from "../config"
|
||||
import { buildAddress } from "../utils/place"
|
||||
|
||||
const M_TO_FT = 3.28084
|
||||
|
||||
function formatDriveTime(seconds) {
|
||||
const mins = Math.round(seconds / 60)
|
||||
if (mins < 2) return "< 2 min"
|
||||
if (mins < 120) return `${mins} min`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 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 (
|
||||
<div className="text-xs" style={{ paddingTop: first ? 0 : "0.5rem", borderTop: first ? "none" : "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-1.5 mb-1.5" style={{ color: "var(--text-tertiary)" }}>
|
||||
<Icon size={12} />
|
||||
<span className="uppercase text-[10px] font-medium tracking-wide">{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HoursDisplay({ hoursStr, first }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const parsed = parseHours(hoursStr)
|
||||
if (!parsed) return null
|
||||
const { isOpen, todayStr, week } = parsed
|
||||
return (
|
||||
<DetailSection label="Hours" icon={Clock} first={first}>
|
||||
<button onClick={() => setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
|
||||
<span style={{ color: isOpen ? "var(--success)" : "var(--text-tertiary)" }}>{todayStr}</span>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 flex flex-col gap-0.5 text-[11px]">
|
||||
{week.map((w) => (
|
||||
<div key={w.day} className="flex justify-between" style={{ color: w.isTodayRow ? "var(--text-primary)" : "var(--text-secondary)", fontWeight: w.isTodayRow ? 500 : 400 }}>
|
||||
<span>{w.day}</span>
|
||||
<span>{w.hours}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
)
|
||||
}
|
||||
|
||||
function LandclassSection({ data }) {
|
||||
if (!data || !data.summary) return null
|
||||
return (
|
||||
<div className="mt-2 flex items-start gap-2 text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||
<Trees size={14} style={{ color: "var(--text-tertiary)", flexShrink: 0, marginTop: 1 }} />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{data.summary}</span>
|
||||
{data.unit_name && <span style={{ color: "var(--text-tertiary)" }}>{data.unit_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivateLandIndicator({ data }) {
|
||||
if (!data || data.gap_status !== "4") return null
|
||||
return (
|
||||
<div className="mt-2 px-2 py-1.5 rounded text-xs" style={{ background: "var(--warning-muted)", color: "var(--warning)", border: "1px solid var(--warning)" }}>
|
||||
Private land — permission required
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnrichmentSkeleton() {
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3 animate-pulse">
|
||||
<div className="h-3 rounded w-1/3" style={{ background: "var(--bg-inset)" }} />
|
||||
<div className="h-3 rounded w-2/3" style={{ background: "var(--bg-inset)" }} />
|
||||
<div className="h-3 rounded w-1/2" style={{ background: "var(--bg-inset)" }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-3 flex flex-col gap-2.5">
|
||||
{hasAbout && (
|
||||
<DetailSection label="About" icon={Info} first={idx++ === 0}>
|
||||
<span className="category-badge">{category}</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
|
||||
{hasContact && (
|
||||
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{et.phone && <a href={"tel:" + et.phone} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Phone size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{formatPhone(et.phone)}</a>}
|
||||
{et.website && <a href={et.website.startsWith("http") ? et.website : "https://" + et.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs truncate" style={{ color: "var(--accent)" }}><Globe size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}</a>}
|
||||
{et.email && <a href={"mailto:" + et.email} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Mail size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.email}</a>}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
{hasDetails && (
|
||||
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")}</span>}
|
||||
{et.operator && <span>Operated by {et.operator}</span>}
|
||||
{et.fee && <span>{et.fee === "no" ? "Free" : "Fee: " + et.fee}</span>}
|
||||
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
|
||||
{et.takeaway === "yes" && <span>Takeaway available</span>}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
{hasLinks && (
|
||||
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{et.wikipedia && wikiUrl(et.wikipedia) && <a href={wikiUrl(et.wikipedia)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}><BookOpen size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{wikiLabel(et.wikipedia)}</a>}
|
||||
{et.wikidata && <a href={"https://www.wikidata.org/wiki/" + et.wikidata} target="_blank" rel="noopener noreferrer" className="text-[11px]" style={{ color: "var(--text-tertiary)", textDecoration: "underline" }}>View on Wikidata</a>}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div ref={ref} className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]" style={{ background: "var(--bg-overlay)", border: "1px solid var(--border)", boxShadow: "var(--shadow-lg)" }}>
|
||||
<button onClick={address ? copyAddress : undefined} disabled={!address} className="w-full text-left px-3 py-1.5 text-xs" style={{ color: address ? "var(--text-primary)" : "var(--text-tertiary)", cursor: address ? "pointer" : "not-allowed" }}>Address</button>
|
||||
<button onClick={copyCoords} className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80" style={{ color: "var(--text-primary)" }}>Coordinates</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="navi-place-card navi-place-card-collapsed flex items-center gap-2 p-2 rounded-lg cursor-pointer" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }} onClick={onToggleExpand}>
|
||||
{draggable && <div {...dragHandleProps} className="cursor-grab" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
|
||||
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
|
||||
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
|
||||
<ChevronDown size={14} style={{ color: "var(--text-tertiary)" }} />
|
||||
{onRemove && <button onClick={(e) => { e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="navi-place-card navi-place-card-expanded flex flex-col rounded-lg p-3" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
{draggable && <div {...dragHandleProps} className="cursor-grab mt-0.5" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
|
||||
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
|
||||
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
|
||||
{place.type && <span className="capitalize">{place.type}</span>}
|
||||
{driveTime != null && <><span>{"\u00b7"}</span><span>{formatDriveTime(driveTime)} drive</span></>}
|
||||
{nearbyLabel && <><span>{"\u00b7"}</span><span>Near {nearbyLabel}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onToggleExpand && variant === "stop" && <button onClick={onToggleExpand} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><ChevronUp size={14} /></button>}
|
||||
{onClose && <button onClick={onClose} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
|
||||
</div>
|
||||
</div>
|
||||
{address && <div className="text-xs mb-2" style={{ color: "var(--text-secondary)" }}>{address}</div>}
|
||||
<div className="flex items-center text-[11px] mb-2" style={{ color: "var(--text-tertiary)" }}>
|
||||
<span>{place.lat.toFixed(6)}, {place.lon.toFixed(6)}</span>
|
||||
<span className="mx-2">{"\u00b7"}</span>
|
||||
<span>{elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}</span>
|
||||
</div>
|
||||
<LandclassSection data={landclass} />
|
||||
<PrivateLandIndicator data={landclass} />
|
||||
{placeDetails === "loading" && <EnrichmentSkeleton />}
|
||||
{placeDetails && placeDetails !== "loading" && <EnrichmentSections details={placeDetails} />}
|
||||
<div className="mt-3 pt-3 flex gap-2" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
{variant === "preview" && (
|
||||
<>
|
||||
<button onClick={handleDirections} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}><Navigation size={13} />Get Directions</button>
|
||||
{existingStopIndex >= 0 ? (
|
||||
<span className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent-muted)", color: "var(--accent)" }}>Stop {String.fromCharCode(65 + existingStopIndex)}</span>
|
||||
) : (
|
||||
<button onClick={handleAddStop} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><Plus size={13} />Add stop</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{variant === "stop" && onRemove && <button onClick={onRemove} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><X size={13} />Remove</button>}
|
||||
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
|
||||
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlaceCard
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
|
||||
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
|
||||
} from 'lucide-react'
|
||||
import OpeningHours from 'opening_hours'
|
||||
|
|
@ -416,6 +416,7 @@ export default function PlaceDetail() {
|
|||
const userLocation = useStore((s) => s.userLocation)
|
||||
const contacts = useStore((s) => s.contacts)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
const auth = useStore((s) => s.auth)
|
||||
|
||||
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
|
@ -742,18 +743,30 @@ export default function PlaceDetail() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg"
|
||||
style={{
|
||||
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
|
||||
color: savedContact ? 'var(--accent)' : 'var(--tan)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
|
||||
>
|
||||
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{auth.authenticated ? (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg"
|
||||
style={{
|
||||
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
|
||||
color: savedContact ? 'var(--accent)' : 'var(--tan)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
|
||||
>
|
||||
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { window.location.href = '/api/auth/whoami' }}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)', border: '1px solid var(--border)' }}
|
||||
title="Log in to save places"
|
||||
>
|
||||
<LogIn size={12} />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Copy dropdown */}
|
||||
<div className="relative">
|
||||
|
|
|
|||
798
src/components/PlaceDetail.jsx.bak.boundary
Normal file
798
src/components/PlaceDetail.jsx.bak.boundary
Normal file
|
|
@ -0,0 +1,798 @@
|
|||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
||||
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
|
||||
} from 'lucide-react'
|
||||
import OpeningHours from 'opening_hours'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api'
|
||||
import { hasFeature } from '../config'
|
||||
import { buildAddress } from '../utils/place'
|
||||
|
||||
/** Meters to feet */
|
||||
const M_TO_FT = 3.28084
|
||||
|
||||
/** Format drive time (seconds) to human-readable string */
|
||||
function formatDriveTime(seconds) {
|
||||
const mins = Math.round(seconds / 60)
|
||||
if (mins < 2) return '< 2 min drive'
|
||||
if (mins < 120) return `${mins} min drive`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 0 ? `${h}h ${m}m drive` : `${h}h drive`
|
||||
}
|
||||
|
||||
// ── Opening hours helpers ──────────────────────────────────────────────
|
||||
|
||||
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 at ${closeTime}`
|
||||
}
|
||||
} else {
|
||||
todayStr = 'Closed'
|
||||
if (nextChange) {
|
||||
const openTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
const isToday = nextChange.getDate() === now.getDate()
|
||||
todayStr += ` \u00b7 Opens ${isToday ? 'at' : 'tomorrow at'} ${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', isToday: 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(', '), isToday: d === now.getDay() })
|
||||
}
|
||||
}
|
||||
|
||||
return { isOpen, todayStr, week }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Formatting helpers ─────────────────────────────────────────────────
|
||||
|
||||
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 match = wp.match(/^([a-z-]+):(.+)$/)
|
||||
if (!match) return null
|
||||
return `https://${match[1]}.wikipedia.org/wiki/${encodeURIComponent(match[2].replace(/ /g, '_'))}`
|
||||
}
|
||||
|
||||
function wikiLabel(wp) {
|
||||
if (!wp) return null
|
||||
const match = wp.match(/^[a-z-]+:(.+)$/)
|
||||
return match ? match[1].replace(/_/g, ' ') : wp
|
||||
}
|
||||
|
||||
// ── Section wrapper ────────────────────────────────────────────────────
|
||||
|
||||
function DetailSection({ label, icon: Icon, first, children }) {
|
||||
return (
|
||||
<div
|
||||
className="place-detail-section"
|
||||
style={first ? {} : { borderTop: '1px solid var(--border-subtle)', paddingTop: '10px' }}
|
||||
>
|
||||
<div className="place-detail-section-header">
|
||||
{Icon && <Icon size={12} style={{ opacity: 0.6 }} />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hours display ──────────────────────────────────────────────────────
|
||||
|
||||
function HoursDisplay({ hoursStr, first }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const parsed = parseHours(hoursStr)
|
||||
|
||||
if (!parsed) {
|
||||
return (
|
||||
<DetailSection label="Hours" icon={Clock} first={first}>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{hoursStr}</p>
|
||||
</DetailSection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailSection label="Hours" icon={Clock} first={first}>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between text-xs"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full mr-1.5"
|
||||
style={{ background: parsed.isOpen ? 'var(--accent)' : 'var(--tan)' }}
|
||||
/>
|
||||
{parsed.todayStr}
|
||||
</span>
|
||||
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 flex flex-col gap-0.5">
|
||||
{parsed.week.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
className="flex justify-between text-xs"
|
||||
style={{
|
||||
color: d.isToday ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: d.isToday ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span>{d.day}</span>
|
||||
<span>{d.hours}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Copy popover ───────────────────────────────────────────────────────
|
||||
|
||||
function CopyPopover({ address, selectedPlace, onClose }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) onClose()
|
||||
}
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const copyAddress = () => {
|
||||
const text = [selectedPlace.name, address].filter(Boolean).join('\n')
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => toast('Address copied'),
|
||||
() => toast.error('Failed to copy')
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const copyCoords = () => {
|
||||
const text = `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => toast('Coordinates copied'),
|
||||
() => toast.error('Failed to copy')
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]"
|
||||
style={{
|
||||
background: 'var(--bg-overlay)',
|
||||
border: '1px solid var(--border)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={address ? copyAddress : undefined}
|
||||
disabled={!address}
|
||||
className="w-full text-left px-3 py-1.5 text-xs"
|
||||
style={{
|
||||
color: address ? 'var(--text-primary)' : 'var(--text-tertiary)',
|
||||
cursor: address ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title={!address ? 'No address available' : undefined}
|
||||
>
|
||||
Address
|
||||
</button>
|
||||
<button
|
||||
onClick={copyCoords}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Coordinates
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Enrichment sections ────────────────────────────────────────────────
|
||||
|
||||
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 (
|
||||
<div className="mt-3 flex flex-col gap-2.5">
|
||||
{hasAbout && (
|
||||
<DetailSection label="About" icon={Info} first={idx++ === 0}>
|
||||
<span className="category-badge">{category}</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
|
||||
|
||||
{hasContact && (
|
||||
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{et.phone && (
|
||||
<a href={`tel:${et.phone}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
|
||||
<Phone size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
{formatPhone(et.phone)}
|
||||
</a>
|
||||
)}
|
||||
{et.website && (
|
||||
<a
|
||||
href={et.website.startsWith('http') ? et.website : `https://${et.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs truncate"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
<Globe size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
{et.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||
</a>
|
||||
)}
|
||||
{et.email && (
|
||||
<a href={`mailto:${et.email}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
|
||||
<Mail size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
{et.email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{hasDetails && (
|
||||
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, ' ').replace(/;/g, ', ')}</span>}
|
||||
{et.operator && <span>Operated by {et.operator}</span>}
|
||||
{et.fee && <span>{et.fee === 'no' ? 'Free' : `Fee: ${et.fee}`}</span>}
|
||||
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
|
||||
{et.takeaway === 'yes' && <span>Takeaway available</span>}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{hasLinks && (
|
||||
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{et.wikipedia && wikiUrl(et.wikipedia) && (
|
||||
<a
|
||||
href={wikiUrl(et.wikipedia)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
<BookOpen size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
{wikiLabel(et.wikipedia)}
|
||||
</a>
|
||||
)}
|
||||
{et.wikidata && (
|
||||
<a
|
||||
href={`https://www.wikidata.org/wiki/${et.wikidata}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs font-mono"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
Wikidata: {et.wikidata}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton loader ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ── Land classification display ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function LandclassSection({ data }) {
|
||||
if (!data || data.is_public !== true || !data.classifications?.length) return null
|
||||
|
||||
return (
|
||||
<DetailSection label="Public Land" icon={Trees}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.classifications.map((c, i) => (
|
||||
<div key={i} className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{c.unit_name}
|
||||
</span>
|
||||
{(c.owner_type || c.manager_name || c.designation_type) && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{[c.owner_type, c.manager_name, c.designation_type].filter(Boolean).join(' \u203a ')}
|
||||
</span>
|
||||
)}
|
||||
{c.public_access && c.public_access !== 'Unknown' && (
|
||||
<span className="category-badge" style={{ fontSize: '10px', width: 'fit-content' }}>
|
||||
{c.public_access}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivateLandIndicator({ data }) {
|
||||
if (!data || data.is_private !== true) return null
|
||||
return (
|
||||
<p className="mt-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Private land
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function EnrichmentSkeleton() {
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-2.5 animate-pulse">
|
||||
<div className="h-3 rounded w-16" style={{ background: 'var(--border-subtle)' }} />
|
||||
<div className="h-3 rounded w-32" style={{ background: 'var(--border-subtle)' }} />
|
||||
<div className="h-3 rounded w-24" style={{ background: 'var(--border-subtle)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────
|
||||
|
||||
export default function PlaceDetail() {
|
||||
const selectedPlace = useStore((s) => s.selectedPlace)
|
||||
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
||||
const startDirections = useStore((s) => s.startDirections)
|
||||
const addStop = useStore((s) => s.addStop)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
const userLocation = useStore((s) => s.userLocation)
|
||||
const contacts = useStore((s) => s.contacts)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
|
||||
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [copyOpen, setCopyOpen] = useState(false)
|
||||
const [placeDetails, setPlaceDetails] = useState(null)
|
||||
const [driveTime, setDriveTime] = useState(null)
|
||||
const [nearbyLabel, setNearbyLabel] = useState(null)
|
||||
const [landclass, setLandclass] = useState(null)
|
||||
|
||||
const closeCopy = useCallback(() => setCopyOpen(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Close copy popover when place changes
|
||||
useEffect(() => { setCopyOpen(false) }, [selectedPlace])
|
||||
|
||||
// Escape key closes panel
|
||||
useEffect(() => {
|
||||
if (!selectedPlace) return
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Escape') clearSelectedPlace()
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [selectedPlace, clearSelectedPlace])
|
||||
|
||||
// Fetch elevation when place changes
|
||||
const placeLat = selectedPlace?.lat
|
||||
const placeLon = selectedPlace?.lon
|
||||
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])
|
||||
|
||||
// Fetch place details when place changes (if feature enabled)
|
||||
const osmType = selectedPlace?.raw?.osm_type
|
||||
const osmId = selectedPlace?.raw?.osm_id
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [osmType, osmId])
|
||||
|
||||
// Fetch wikidata enrichment when place has wikidata but no OSM details
|
||||
const wikidataId = selectedPlace?.wikidata || selectedPlace?.raw?.wikidata
|
||||
useEffect(() => {
|
||||
// Skip if OSM details are available (they provide richer data)
|
||||
if (osmType && osmId) return
|
||||
// Skip if no wikidata ID
|
||||
if (!wikidataId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
|
||||
if (!controller.signal.aborted && data) {
|
||||
// Merge wikidata info into placeDetails (description, population, etc.)
|
||||
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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [wikidataId, osmType, osmId])
|
||||
|
||||
// Fetch drive time when place or user location changes
|
||||
useEffect(() => {
|
||||
if (!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)
|
||||
}
|
||||
}, [userLocation?.lat, userLocation?.lon, placeLat, placeLon])
|
||||
|
||||
// Fetch nearby contacts for proximity annotation
|
||||
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])
|
||||
|
||||
// Fetch land classification when place changes (if feature enabled)
|
||||
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)
|
||||
// Upgrade "Dropped pin" name to land summary if reverse geocode didn't resolve
|
||||
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])
|
||||
|
||||
// Derive elevation/loading from comparing result to current place
|
||||
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
|
||||
const elevation = !elevLoading ? elevResult.value : null
|
||||
|
||||
if (!selectedPlace) return null
|
||||
|
||||
const address = buildAddress(selectedPlace)
|
||||
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
|
||||
|
||||
// Check if place is already in stops
|
||||
const existingStopIndex = stops.findIndex(
|
||||
(s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001
|
||||
)
|
||||
|
||||
// Check if place is already saved as a contact
|
||||
const savedContact = hasFeature('has_contacts')
|
||||
? contacts.find((c) => {
|
||||
if (c.osm_type && c.osm_id && osmType && osmId) {
|
||||
return c.osm_type === osmType && c.osm_id === osmId
|
||||
}
|
||||
if (c.lat != null && c.lon != null) {
|
||||
return Math.abs(c.lat - selectedPlace.lat) < 0.0001 && Math.abs(c.lon - selectedPlace.lon) < 0.0001
|
||||
}
|
||||
return false
|
||||
})
|
||||
: null
|
||||
|
||||
const handleDirections = () => {
|
||||
startDirections(selectedPlace)
|
||||
if (geoPermission !== 'granted' && stops.length === 0) {
|
||||
toast('Set a starting point to get directions', { icon: '\u{1F4CD}' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddStop = () => {
|
||||
addStop({
|
||||
lat: selectedPlace.lat,
|
||||
lon: selectedPlace.lon,
|
||||
name: selectedPlace.name,
|
||||
source: selectedPlace.source,
|
||||
matchCode: selectedPlace.matchCode,
|
||||
})
|
||||
clearSelectedPlace()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasFeature('has_contacts')) {
|
||||
toast('Saved places coming soon')
|
||||
return
|
||||
}
|
||||
if (savedContact) {
|
||||
// Edit existing contact
|
||||
setEditingContact(savedContact)
|
||||
} else {
|
||||
// New contact pre-populated from place
|
||||
setEditingContact({
|
||||
label: '',
|
||||
lat: selectedPlace.lat,
|
||||
lon: selectedPlace.lon,
|
||||
osm_type: osmType || null,
|
||||
osm_id: osmId || null,
|
||||
address: address || '',
|
||||
name: selectedPlace.type === 'poi' && selectedPlace.raw?.name ? selectedPlace.raw.name : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const panelContent = (
|
||||
<>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={clearSelectedPlace}
|
||||
className="absolute top-3 right-3 p-1 rounded"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{/* Place name */}
|
||||
<div className="pr-8">
|
||||
<h2 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{selectedPlace.type === 'poi' && selectedPlace.raw?.name
|
||||
? selectedPlace.raw.name
|
||||
: selectedPlace.name}
|
||||
</h2>
|
||||
{(() => {
|
||||
const cat = placeDetails && placeDetails !== 'loading' ? placeDetails.category : null
|
||||
const parts = []
|
||||
if (cat) parts.push(cat)
|
||||
if (nearbyLabel) parts.push(`near ${nearbyLabel}`)
|
||||
if (driveTime != null) parts.push(formatDriveTime(driveTime))
|
||||
if (parts.length === 0) return null
|
||||
return (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{parts.join(' \u00b7 ')}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{address && (
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Coordinates + elevation */}
|
||||
<div className="mt-3 font-mono text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span>{selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)}</span>
|
||||
<span className="mx-2">·</span>
|
||||
<span>
|
||||
{elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* OSM enrichment sections */}
|
||||
{/* Land classification (PAD-US) */}
|
||||
<LandclassSection data={landclass} />
|
||||
<PrivateLandIndicator data={landclass} />
|
||||
|
||||
{/* OSM enrichment sections */}
|
||||
{placeDetails === 'loading' && <EnrichmentSkeleton />}
|
||||
{placeDetails && placeDetails !== 'loading' && <EnrichmentSections details={placeDetails} />}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-auto pt-4 flex gap-2">
|
||||
<button
|
||||
onClick={handleDirections}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
|
||||
>
|
||||
<Navigation size={13} />
|
||||
Directions
|
||||
</button>
|
||||
|
||||
{existingStopIndex >= 0 ? (
|
||||
<span
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
Added as stop {String.fromCharCode(65 + existingStopIndex)}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddStop}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add stop
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg"
|
||||
style={{
|
||||
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
|
||||
color: savedContact ? 'var(--accent)' : 'var(--tan)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
|
||||
>
|
||||
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
{/* Copy dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setCopyOpen((v) => !v)}
|
||||
className="p-2 rounded-lg flex items-center gap-0.5"
|
||||
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
||||
aria-label="Copy"
|
||||
>
|
||||
<Copy size={14} />
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{copyOpen && (
|
||||
<CopyPopover
|
||||
address={address}
|
||||
selectedPlace={selectedPlace}
|
||||
onClose={closeCopy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
// Mobile: bottom overlay
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="navi-place-detail navi-place-detail-active fixed bottom-0 left-0 right-0 z-20 p-4 rounded-t-2xl flex flex-col"
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: side panel
|
||||
return (
|
||||
<div
|
||||
className="navi-place-detail navi-place-detail-active absolute top-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
left: '20rem',
|
||||
width: '360px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
320
src/components/SearchBar.jsx.bak.twoclick
Normal file
320
src/components/SearchBar.jsx.bak.twoclick
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X, User } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { buildAddress } from '../utils/place'
|
||||
import { searchGeocode } from '../api'
|
||||
import { hasFeature } from '../config'
|
||||
|
||||
/** Get category icon based on result type/source */
|
||||
function CategoryIcon({ result }) {
|
||||
const type = result.type || ''
|
||||
const source = result.source || ''
|
||||
const size = 14
|
||||
|
||||
if (result._isContact) return <User size={size} />
|
||||
if (source === 'nickname') return <Star size={size} />
|
||||
if (type === 'coordinates') return <Crosshair size={size} />
|
||||
if (type === 'locality' || type === 'city') return <Building2 size={size} />
|
||||
|
||||
// POI subcategories from osm_value if available
|
||||
const osmVal = result.raw?.osm_value || ''
|
||||
if (osmVal.includes('cafe') || osmVal.includes('coffee')) return <Coffee size={size} />
|
||||
if (osmVal.includes('fuel') || osmVal.includes('gas')) return <Fuel size={size} />
|
||||
if (osmVal.includes('shop') || osmVal.includes('supermarket')) return <ShoppingBag size={size} />
|
||||
if (osmVal.includes('hotel') || osmVal.includes('motel')) return <Hotel size={size} />
|
||||
|
||||
return <MapPin size={size} />
|
||||
}
|
||||
|
||||
const SearchBar = forwardRef(function SearchBar(_, ref) {
|
||||
const inputRef = useRef(null)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}))
|
||||
|
||||
const query = useStore((s) => s.query)
|
||||
const results = useStore((s) => s.results)
|
||||
const searchLoading = useStore((s) => s.searchLoading)
|
||||
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const pendingDestination = useStore((s) => s.pendingDestination)
|
||||
const contacts = useStore((s) => s.contacts)
|
||||
const setQuery = useStore((s) => s.setQuery)
|
||||
const setResults = useStore((s) => s.setResults)
|
||||
const setSearchLoading = useStore((s) => s.setSearchLoading)
|
||||
const setAbortController = useStore((s) => s.setAbortController)
|
||||
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
|
||||
const addStop = useStore((s) => s.addStop)
|
||||
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
|
||||
const mapCenter = useStore((s) => s.mapCenter)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q) => {
|
||||
const prev = useStore.getState().abortController
|
||||
if (prev) prev.abort()
|
||||
|
||||
if (!q.trim()) {
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
setSearchLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepend matching contacts
|
||||
let contactResults = []
|
||||
if (hasFeature('has_contacts') && contacts.length > 0) {
|
||||
const lower = q.trim().toLowerCase()
|
||||
contactResults = contacts
|
||||
.filter((c) =>
|
||||
(c.label || '').toLowerCase().startsWith(lower) ||
|
||||
(c.name || '').toLowerCase().startsWith(lower) ||
|
||||
(c.call_sign || '').toLowerCase().startsWith(lower)
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((c) => ({
|
||||
lat: c.lat,
|
||||
lon: c.lon,
|
||||
name: c.label,
|
||||
address: c.address || c.name || '',
|
||||
type: 'contact',
|
||||
source: 'contacts',
|
||||
match_code: null,
|
||||
raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
|
||||
_isContact: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const ctrl = new AbortController()
|
||||
setAbortController(ctrl)
|
||||
setSearchLoading(true)
|
||||
|
||||
try {
|
||||
const data = await searchGeocode(q.trim(), 6, ctrl.signal, mapCenter)
|
||||
const combined = [...contactResults, ...(data.results || [])]
|
||||
setResults(combined)
|
||||
setAutocompleteOpen(combined.length > 0)
|
||||
setActiveIndex(-1)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
// Still show contacts even if geocode fails
|
||||
if (contactResults.length > 0) {
|
||||
setResults(contactResults)
|
||||
setAutocompleteOpen(true)
|
||||
} else {
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
},
|
||||
[setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
|
||||
)
|
||||
|
||||
const handleChange = (e) => {
|
||||
const val = e.target.value
|
||||
setQuery(val)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => doSearch(val), 150)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const selectResult = (result) => {
|
||||
const { pendingDestination: pending } = useStore.getState()
|
||||
|
||||
// Pure contact (no geo) → open edit modal
|
||||
if (result._isContact && result.lat == null) {
|
||||
setEditingContact(result.raw.contact)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
setActiveIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
|
||||
addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
|
||||
clearPendingDestination()
|
||||
toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
|
||||
} else {
|
||||
setSelectedPlace({
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
name: result.name,
|
||||
address: result.address || null,
|
||||
type: result.type,
|
||||
source: result.source,
|
||||
matchCode: result.match_code,
|
||||
raw: result.raw || {},
|
||||
})
|
||||
}
|
||||
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
setActiveIndex(-1)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!autocompleteOpen || results.length === 0) {
|
||||
if (e.key === 'Escape') setAutocompleteOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
selectResult(results[activeIndex])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setAutocompleteOpen(false)
|
||||
setActiveIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const atCap = stops.length >= 10
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
|
||||
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
|
||||
disabled={atCap}
|
||||
className="navi-input w-full pr-8"
|
||||
aria-label="Search places"
|
||||
aria-expanded={autocompleteOpen}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
/>
|
||||
{/* Clear / Loading indicator */}
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
{searchLoading ? (
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
|
||||
/>
|
||||
) : query ? (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-0.5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Autocomplete dropdown */}
|
||||
{autocompleteOpen && results.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-72 overflow-y-auto"
|
||||
style={{
|
||||
background: 'var(--bg-overlay)',
|
||||
border: '1px solid var(--border)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{results.map((r, i) => {
|
||||
const isPoi = r.type === 'poi' && r.raw?.name
|
||||
const isContact = r._isContact
|
||||
const primary = isContact ? r.name : isPoi ? r.raw.name : r.name
|
||||
const secondary = isContact ? (r.address || '') : isPoi ? buildAddress(r) : null
|
||||
return (
|
||||
<li
|
||||
key={`${r.lat}-${r.lon}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className="px-3 py-2 cursor-pointer text-sm"
|
||||
style={{
|
||||
background: i === activeIndex
|
||||
? 'var(--accent-muted)'
|
||||
: isContact
|
||||
? 'var(--accent-muted)'
|
||||
: 'transparent',
|
||||
borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
|
||||
opacity: isContact && i !== activeIndex ? 0.85 : 1,
|
||||
}}
|
||||
onClick={() => selectResult(r)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0" style={{ color: isContact ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
||||
<CategoryIcon result={r} />
|
||||
</span>
|
||||
<span className="truncate flex-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{primary}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{isContact && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
{r.match_code?.housenumber === 'matched' && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
exact
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{secondary && (
|
||||
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{secondary}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default SearchBar
|
||||
117
src/components/StopList.jsx.bak.pending
Normal file
117
src/components/StopList.jsx.bak.pending
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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 (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<PlaceCard
|
||||
place={place}
|
||||
variant="stop"
|
||||
expanded={expanded}
|
||||
onToggleExpand={() => setExpanded(!expanded)}
|
||||
onRemove={() => removeStop(stop.id)}
|
||||
stopIndex={index + indexOffset}
|
||||
draggable={true}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="text-xs px-2 py-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{pendingDestination
|
||||
? 'Search for a starting point above'
|
||||
: geoPermission === 'denied'
|
||||
? 'Add a starting point and destination above'
|
||||
: 'Search and add stops to build your route'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasGpsOrigin && <GpsOriginItem />}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||
{stops.map((stop, i) => (
|
||||
<SortableStopCard
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
index={i}
|
||||
indexOffset={indexOffset}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
537
src/index.css.bak.twoclick
Normal file
537
src/index.css.bak.twoclick
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
NAVI DESIGN TOKENS
|
||||
Warm grays, sage greens, khaki tans, deep blacks.
|
||||
No blue in UI chrome.
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* ── Typography ── */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* ── Type scale ── */
|
||||
--text-xs: 0.6875rem; /* 11px */
|
||||
--text-sm: 0.8125rem; /* 13px */
|
||||
--text-base: 0.875rem; /* 14px */
|
||||
--text-md: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
}
|
||||
|
||||
/* ═══ DARK MODE (default) ═══ */
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #1c1917; /* warm off-black (was #0f1210) */
|
||||
--bg-raised: #252220; /* raised surface (was #181d1a) */
|
||||
--bg-overlay: #2e2a27; /* overlay/dropdown (was #1e2522) */
|
||||
--bg-input: #201d1a; /* input fields (was #141a16) */
|
||||
|
||||
--text-primary: #dde3dc;
|
||||
--text-secondary: #8f9a8e;
|
||||
--text-tertiary: #5e6b5d;
|
||||
--text-inverse: #1c1917;
|
||||
|
||||
--border: #3a3530; /* warm brown-gray (was #2a3329) */
|
||||
--border-subtle: #2a2624; /* (was #1f261e) */
|
||||
|
||||
--accent: #7a9a6b; /* sage green — interactive states */
|
||||
--accent-hover: #8fad7f;
|
||||
--accent-muted: #3d4d36;
|
||||
|
||||
--tan: #b8a88a; /* khaki — secondary highlights */
|
||||
--tan-muted: #4a4235;
|
||||
|
||||
--pin-origin: #6b8f5e; /* sage */
|
||||
--pin-destination: #a67c52; /* rust/tan */
|
||||
--pin-intermediate: #6b7268; /* warm gray */
|
||||
--pin-stroke: #1c1917;
|
||||
|
||||
--status-success: #6b8f5e;
|
||||
--status-warning: #b89a4a;
|
||||
--status-danger: #a65c52;
|
||||
|
||||
--route-line: #7a9a6b;
|
||||
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ═══ LIGHT MODE ═══ */
|
||||
[data-theme="light"] {
|
||||
--bg-base: #ddd2b9; /* warm khaki-tan (was #ece8e1) */
|
||||
--bg-raised: #e8dec8; /* raised surface (was #f5f2ec) */
|
||||
--bg-overlay: #e3d9c1; /* overlay/dropdown (was #f0ece5) */
|
||||
--bg-input: #e8dec8; /* input fields (was #f5f2ec) */
|
||||
|
||||
--text-primary: #1a1d1a;
|
||||
--text-secondary: #4f5a49; /* darkened for WCAG AA on new base (was #5c6558) */
|
||||
--text-tertiary: #7a8674; /* darkened proportionally (was #8a9486) */
|
||||
--text-inverse: #f5f2ed;
|
||||
|
||||
--border: #c4b89e; /* warmer border (was #d4cfc5) */
|
||||
--border-subtle: #d5cab2; /* warmer subtle border (was #e8e3db) */
|
||||
|
||||
--accent: #4a7040;
|
||||
--accent-hover: #3d5e35;
|
||||
--accent-muted: #dce8d6;
|
||||
|
||||
--tan: #8a7556;
|
||||
--tan-muted: #f0e8d8;
|
||||
|
||||
--pin-origin: #4a7040;
|
||||
--pin-destination: #8a5c35;
|
||||
--pin-intermediate: #6b6960;
|
||||
--pin-stroke: #1a1d1a;
|
||||
|
||||
--status-success: #4a7040;
|
||||
--status-warning: #8a7040;
|
||||
--status-danger: #8a4040;
|
||||
|
||||
--route-line: #4a7040;
|
||||
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* ═══ BASE STYLES ═══ */
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Mono class utility */
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ═══ FOCUS RING — accent, never blue ═══ */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ═══ TRANSITIONS — respect reduced motion ═══ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ MAPLIBRE POPUP ═══ */
|
||||
.maplibregl-popup-content {
|
||||
background: var(--bg-raised) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 8px 12px !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
border-top-color: var(--bg-raised) !important;
|
||||
border-bottom-color: var(--bg-raised) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 16px !important;
|
||||
padding: 2px 6px !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button:hover {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ═══ SCROLLBAR ═══ */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ═══ GPS CHEVRON MARKER ═══ */
|
||||
.navi-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.navi-gps-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--bg-raised);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
/* ═══ STOP PIN MARKERS (map) ═══ */
|
||||
.navi-pin {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border: 2px solid var(--pin-stroke);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.navi-pin--origin { background: var(--pin-origin); }
|
||||
.navi-pin--destination { background: var(--pin-destination); }
|
||||
.navi-pin--intermediate { background: var(--pin-intermediate); }
|
||||
|
||||
/* ═══ FORM ELEMENTS ═══ */
|
||||
.navi-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
|
||||
.navi-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.navi-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||
}
|
||||
|
||||
.navi-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.navi-btn-secondary {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
background: var(--tan-muted);
|
||||
color: var(--tan);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.navi-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.navi-btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ═══ PREVIEW PIN (selected but not committed) ═══ */
|
||||
.navi-pin-preview {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--accent);
|
||||
background: transparent;
|
||||
box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ═══ PLACE DETAIL PANEL ═══ */
|
||||
.navi-place-detail {
|
||||
transition: transform 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.navi-place-detail-enter {
|
||||
transform: translateX(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.navi-place-detail-active {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ═══ LAYER CONTROL ═══ */
|
||||
.layer-control {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.layer-control-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.layer-control-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.layer-control-popover {
|
||||
position: absolute;
|
||||
bottom: 44px;
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.layer-control-header {
|
||||
padding: 4px 12px 6px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.layer-control-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.layer-control-item:hover {
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.layer-control-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.layer-control-toggle {
|
||||
appearance: none;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
background: var(--border);
|
||||
border-radius: 9px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.layer-control-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.layer-control-toggle:checked {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.layer-control-toggle:checked::after {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
|
||||
/* ═══ PLACE DETAIL ENRICHMENT ═══ */
|
||||
.place-detail-section {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.place-detail-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-bottom: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* ═══ LOCATE BUTTON ═══ */
|
||||
.locate-btn {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.locate-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ═══ STOP REMOVE BUTTON (touch-friendly) ═══ */
|
||||
.stop-remove-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.group:hover .stop-remove-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ═══ MOBILE OVERRIDES ═══ */
|
||||
@media (max-width: 767px) {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-control {
|
||||
bottom: auto;
|
||||
top: 120px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.locate-btn {
|
||||
bottom: auto;
|
||||
top: 166px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.stop-remove-btn {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ CONTACT MODAL ═══ */
|
||||
.contact-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.contact-modal {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* ═══ PANEL TAB BAR ═══ */
|
||||
.navi-tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.navi-tab {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.navi-tab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.navi-tab-active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ═══ CONTACT LIST ITEMS ═══ */
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.contact-item:hover {
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
|
@ -109,6 +109,10 @@ export const useStore = create((set, get) => ({
|
|||
localStorage.removeItem('navi-theme-override')
|
||||
}
|
||||
},
|
||||
// ── Auth state ──
|
||||
auth: { authenticated: false, username: null, loaded: false },
|
||||
setAuth: (auth) => set({ auth: { ...auth, loaded: true } }),
|
||||
|
||||
// ── Contacts ──
|
||||
contacts: [],
|
||||
contactsLoaded: false,
|
||||
|
|
|
|||
141
src/store.js.bak.dupstop
Normal file
141
src/store.js.bak.dupstop
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
export const useStore = create((set, get) => ({
|
||||
// ── Search state ──
|
||||
query: '',
|
||||
results: [],
|
||||
searchLoading: false,
|
||||
abortController: null,
|
||||
|
||||
setQuery: (query) => set({ query }),
|
||||
setResults: (results) => set({ results }),
|
||||
setSearchLoading: (loading) => set({ searchLoading: loading }),
|
||||
setAbortController: (ctrl) => set({ abortController: ctrl }),
|
||||
|
||||
// ── Stop list ──
|
||||
stops: [],
|
||||
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
|
||||
|
||||
addStop: (stop) => {
|
||||
const { stops } = get()
|
||||
if (stops.length >= 10) return false
|
||||
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
|
||||
return true
|
||||
},
|
||||
|
||||
removeStop: (id) => {
|
||||
set({ stops: get().stops.filter((s) => s.id !== id) })
|
||||
},
|
||||
|
||||
reorderStops: (newStops) => set({ stops: newStops }),
|
||||
|
||||
clearStops: () => set({ stops: [] }),
|
||||
|
||||
setStops: (stops) => set({ stops }),
|
||||
|
||||
// ── Geolocation ──
|
||||
userLocation: null, // { lat, lon }
|
||||
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
|
||||
|
||||
setUserLocation: (loc) => set({ userLocation: loc }),
|
||||
setGeoPermission: (p) => set({ geoPermission: p }),
|
||||
|
||||
// ── Map viewport (for search bias) ──
|
||||
mapCenter: null, // { lat, lon, zoom }
|
||||
setMapCenter: (center) => set({ mapCenter: center }),
|
||||
|
||||
// ── Mode ──
|
||||
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
// ── Route ──
|
||||
route: null, // Valhalla response (trip object)
|
||||
routeLoading: false,
|
||||
routeError: null,
|
||||
|
||||
setRoute: (route) => set({ route, routeError: null }),
|
||||
setRouteLoading: (loading) => set({ routeLoading: loading }),
|
||||
setRouteError: (err) => set({ routeError: err, route: null }),
|
||||
clearRoute: () => set({ route: null, routeError: null }),
|
||||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
|
||||
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
|
||||
setClickMarker: (marker) => set({ clickMarker: marker }),
|
||||
clearClickMarker: () => set({ clickMarker: null }),
|
||||
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||
|
||||
startDirections: (place) => {
|
||||
const { geoPermission, stops, addStop, clearStops } = get()
|
||||
if (geoPermission === 'granted') {
|
||||
clearStops()
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ gpsOrigin: true, selectedPlace: null })
|
||||
} else if (stops.length > 0) {
|
||||
const origin = stops[0]
|
||||
clearStops()
|
||||
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
|
||||
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 })
|
||||
}
|
||||
},
|
||||
|
||||
// ── UI state ──
|
||||
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
||||
panelOpen: true,
|
||||
autocompleteOpen: false,
|
||||
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
|
||||
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
|
||||
|
||||
setSheetState: (s) => set({ sheetState: s }),
|
||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setThemeOverride: (override) => {
|
||||
set({ themeOverride: override })
|
||||
if (override) {
|
||||
localStorage.setItem('navi-theme-override', override)
|
||||
} else {
|
||||
localStorage.removeItem('navi-theme-override')
|
||||
}
|
||||
},
|
||||
// ── Contacts ──
|
||||
contacts: [],
|
||||
contactsLoaded: false,
|
||||
activeTab: 'routes', // 'routes' | 'contacts'
|
||||
editingContact: null, // null=closed, {}=new, {id:N}=edit
|
||||
|
||||
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setEditingContact: (c) => set({ editingContact: c }),
|
||||
clearEditingContact: () => set({ editingContact: null }),
|
||||
}))
|
||||
|
||||
// ── Panel state selector ──
|
||||
// Returns string state, prioritizing preview to allow it alongside any route state
|
||||
export const usePanelState = () => {
|
||||
return useStore((s) => {
|
||||
const hasPreview = !!s.selectedPlace
|
||||
const hasRoute = !!s.route
|
||||
const hasStops = s.stops.length >= 1
|
||||
|
||||
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
|
||||
if (hasPreview && hasStops) return "PREVIEW_ROUTING"
|
||||
if (hasPreview) return "PREVIEW"
|
||||
if (hasRoute) return "ROUTE_CALCULATED"
|
||||
if (hasStops) return "ROUTING"
|
||||
return "IDLE"
|
||||
})
|
||||
}
|
||||
139
src/store.js.bak.regressions
Normal file
139
src/store.js.bak.regressions
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
export const useStore = create((set, get) => ({
|
||||
// ── Search state ──
|
||||
query: '',
|
||||
results: [],
|
||||
searchLoading: false,
|
||||
abortController: null,
|
||||
|
||||
setQuery: (query) => set({ query }),
|
||||
setResults: (results) => set({ results }),
|
||||
setSearchLoading: (loading) => set({ searchLoading: loading }),
|
||||
setAbortController: (ctrl) => set({ abortController: ctrl }),
|
||||
|
||||
// ── Stop list ──
|
||||
stops: [],
|
||||
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
|
||||
|
||||
addStop: (stop) => {
|
||||
const { stops } = get()
|
||||
if (stops.length >= 10) return false
|
||||
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
|
||||
return true
|
||||
},
|
||||
|
||||
removeStop: (id) => {
|
||||
set({ stops: get().stops.filter((s) => s.id !== id) })
|
||||
},
|
||||
|
||||
reorderStops: (newStops) => set({ stops: newStops }),
|
||||
|
||||
clearStops: () => set({ stops: [] }),
|
||||
|
||||
setStops: (stops) => set({ stops }),
|
||||
|
||||
// ── Geolocation ──
|
||||
userLocation: null, // { lat, lon }
|
||||
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
|
||||
|
||||
setUserLocation: (loc) => set({ userLocation: loc }),
|
||||
setGeoPermission: (p) => set({ geoPermission: p }),
|
||||
|
||||
// ── Map viewport (for search bias) ──
|
||||
mapCenter: null, // { lat, lon, zoom }
|
||||
setMapCenter: (center) => set({ mapCenter: center }),
|
||||
|
||||
// ── Mode ──
|
||||
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
// ── Route ──
|
||||
route: null, // Valhalla response (trip object)
|
||||
routeLoading: false,
|
||||
routeError: null,
|
||||
|
||||
setRoute: (route) => set({ route, routeError: null }),
|
||||
setRouteLoading: (loading) => set({ routeLoading: loading }),
|
||||
setRouteError: (err) => set({ routeError: err, route: null }),
|
||||
clearRoute: () => set({ route: null, routeError: null }),
|
||||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
|
||||
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
|
||||
setClickMarker: (marker) => set({ clickMarker: marker }),
|
||||
clearClickMarker: () => set({ clickMarker: null }),
|
||||
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||
|
||||
startDirections: (place) => {
|
||||
const { geoPermission, stops, addStop, clearStops } = get()
|
||||
if (geoPermission === 'granted') {
|
||||
clearStops()
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ gpsOrigin: true, selectedPlace: null })
|
||||
} else if (stops.length > 0) {
|
||||
const origin = stops[0]
|
||||
clearStops()
|
||||
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
|
||||
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 })
|
||||
}
|
||||
},
|
||||
|
||||
// ── UI state ──
|
||||
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
||||
panelOpen: true,
|
||||
autocompleteOpen: false,
|
||||
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
|
||||
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
|
||||
|
||||
setSheetState: (s) => set({ sheetState: s }),
|
||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setThemeOverride: (override) => {
|
||||
set({ themeOverride: override })
|
||||
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')
|
||||
}
|
||||
},
|
||||
// ── Contacts ──
|
||||
contacts: [],
|
||||
contactsLoaded: false,
|
||||
activeTab: 'routes', // 'routes' | 'contacts'
|
||||
editingContact: null, // null=closed, {}=new, {id:N}=edit
|
||||
|
||||
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
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"
|
||||
})
|
||||
}
|
||||
118
src/store.js.bak.twoclick
Normal file
118
src/store.js.bak.twoclick
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
export const useStore = create((set, get) => ({
|
||||
// ── Search state ──
|
||||
query: '',
|
||||
results: [],
|
||||
searchLoading: false,
|
||||
abortController: null,
|
||||
|
||||
setQuery: (query) => set({ query }),
|
||||
setResults: (results) => set({ results }),
|
||||
setSearchLoading: (loading) => set({ searchLoading: loading }),
|
||||
setAbortController: (ctrl) => set({ abortController: ctrl }),
|
||||
|
||||
// ── Stop list ──
|
||||
stops: [],
|
||||
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
|
||||
|
||||
addStop: (stop) => {
|
||||
const { stops } = get()
|
||||
if (stops.length >= 10) return false
|
||||
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
|
||||
return true
|
||||
},
|
||||
|
||||
removeStop: (id) => {
|
||||
set({ stops: get().stops.filter((s) => s.id !== id) })
|
||||
},
|
||||
|
||||
reorderStops: (newStops) => set({ stops: newStops }),
|
||||
|
||||
clearStops: () => set({ stops: [] }),
|
||||
|
||||
setStops: (stops) => set({ stops }),
|
||||
|
||||
// ── Geolocation ──
|
||||
userLocation: null, // { lat, lon }
|
||||
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
|
||||
|
||||
setUserLocation: (loc) => set({ userLocation: loc }),
|
||||
setGeoPermission: (p) => set({ geoPermission: p }),
|
||||
|
||||
// ── Map viewport (for search bias) ──
|
||||
mapCenter: null, // { lat, lon, zoom }
|
||||
setMapCenter: (center) => set({ mapCenter: center }),
|
||||
|
||||
// ── Mode ──
|
||||
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
// ── Route ──
|
||||
route: null, // Valhalla response (trip object)
|
||||
routeLoading: false,
|
||||
routeError: null,
|
||||
|
||||
setRoute: (route) => set({ route, routeError: null }),
|
||||
setRouteLoading: (loading) => set({ routeLoading: loading }),
|
||||
setRouteError: (err) => set({ routeError: err, route: null }),
|
||||
clearRoute: () => set({ route: null, routeError: null }),
|
||||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null }),
|
||||
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||
|
||||
startDirections: (place) => {
|
||||
const { geoPermission, stops, addStop, clearStops } = get()
|
||||
if (geoPermission === 'granted') {
|
||||
clearStops()
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ gpsOrigin: true, selectedPlace: null })
|
||||
} else if (stops.length > 0) {
|
||||
const origin = stops[0]
|
||||
clearStops()
|
||||
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ selectedPlace: null })
|
||||
} else {
|
||||
set({ pendingDestination: place, selectedPlace: null })
|
||||
}
|
||||
},
|
||||
|
||||
// ── UI state ──
|
||||
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
||||
panelOpen: true,
|
||||
autocompleteOpen: false,
|
||||
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
|
||||
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
|
||||
|
||||
setSheetState: (s) => set({ sheetState: s }),
|
||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setThemeOverride: (override) => {
|
||||
set({ themeOverride: override })
|
||||
if (override) {
|
||||
localStorage.setItem('navi-theme-override', override)
|
||||
} else {
|
||||
localStorage.removeItem('navi-theme-override')
|
||||
}
|
||||
},
|
||||
// ── Contacts ──
|
||||
contacts: [],
|
||||
contactsLoaded: false,
|
||||
activeTab: 'routes', // 'routes' | 'contacts'
|
||||
editingContact: null, // null=closed, {}=new, {id:N}=edit
|
||||
|
||||
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setEditingContact: (c) => set({ editingContact: c }),
|
||||
clearEditingContact: () => set({ editingContact: null }),
|
||||
}))
|
||||
133
src/store.js.bak.uxfix
Normal file
133
src/store.js.bak.uxfix
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
export const useStore = create((set, get) => ({
|
||||
// ── Search state ──
|
||||
query: '',
|
||||
results: [],
|
||||
searchLoading: false,
|
||||
abortController: null,
|
||||
|
||||
setQuery: (query) => set({ query }),
|
||||
setResults: (results) => set({ results }),
|
||||
setSearchLoading: (loading) => set({ searchLoading: loading }),
|
||||
setAbortController: (ctrl) => set({ abortController: ctrl }),
|
||||
|
||||
// ── Stop list ──
|
||||
stops: [],
|
||||
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
|
||||
|
||||
addStop: (stop) => {
|
||||
const { stops } = get()
|
||||
if (stops.length >= 10) return false
|
||||
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
|
||||
return true
|
||||
},
|
||||
|
||||
removeStop: (id) => {
|
||||
set({ stops: get().stops.filter((s) => s.id !== id) })
|
||||
},
|
||||
|
||||
reorderStops: (newStops) => set({ stops: newStops }),
|
||||
|
||||
clearStops: () => set({ stops: [] }),
|
||||
|
||||
setStops: (stops) => set({ stops }),
|
||||
|
||||
// ── Geolocation ──
|
||||
userLocation: null, // { lat, lon }
|
||||
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
|
||||
|
||||
setUserLocation: (loc) => set({ userLocation: loc }),
|
||||
setGeoPermission: (p) => set({ geoPermission: p }),
|
||||
|
||||
// ── Map viewport (for search bias) ──
|
||||
mapCenter: null, // { lat, lon, zoom }
|
||||
setMapCenter: (center) => set({ mapCenter: center }),
|
||||
|
||||
// ── Mode ──
|
||||
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
// ── Route ──
|
||||
route: null, // Valhalla response (trip object)
|
||||
routeLoading: false,
|
||||
routeError: null,
|
||||
|
||||
setRoute: (route) => set({ route, routeError: null }),
|
||||
setRouteLoading: (loading) => set({ routeLoading: loading }),
|
||||
setRouteError: (err) => set({ routeError: err, route: null }),
|
||||
clearRoute: () => set({ route: null, routeError: null }),
|
||||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
|
||||
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
|
||||
setClickMarker: (marker) => set({ clickMarker: marker }),
|
||||
clearClickMarker: () => set({ clickMarker: null }),
|
||||
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||
|
||||
startDirections: (place) => {
|
||||
const { geoPermission, stops, addStop, clearStops } = get()
|
||||
if (geoPermission === 'granted') {
|
||||
clearStops()
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ gpsOrigin: true, selectedPlace: null })
|
||||
} else if (stops.length > 0) {
|
||||
const origin = stops[0]
|
||||
clearStops()
|
||||
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
|
||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||
set({ selectedPlace: null })
|
||||
} else {
|
||||
set({ pendingDestination: place, selectedPlace: null })
|
||||
}
|
||||
},
|
||||
|
||||
// ── UI state ──
|
||||
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
||||
panelOpen: true,
|
||||
autocompleteOpen: false,
|
||||
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
|
||||
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
|
||||
|
||||
setSheetState: (s) => set({ sheetState: s }),
|
||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setThemeOverride: (override) => {
|
||||
set({ themeOverride: override })
|
||||
if (override) {
|
||||
localStorage.setItem('navi-theme-override', override)
|
||||
} else {
|
||||
localStorage.removeItem('navi-theme-override')
|
||||
}
|
||||
},
|
||||
// ── Contacts ──
|
||||
contacts: [],
|
||||
contactsLoaded: false,
|
||||
activeTab: 'routes', // 'routes' | 'contacts'
|
||||
editingContact: null, // null=closed, {}=new, {id:N}=edit
|
||||
|
||||
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
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"
|
||||
})
|
||||
}
|
||||
|
|
@ -40,6 +40,10 @@ export const useStore = create((set, get) => ({
|
|||
setUserLocation: (loc) => set({ userLocation: loc }),
|
||||
setGeoPermission: (p) => set({ geoPermission: p }),
|
||||
|
||||
// ── Map viewport (for search bias) ──
|
||||
mapCenter: null, // { lat, lon, zoom }
|
||||
setMapCenter: (center) => set({ mapCenter: center }),
|
||||
|
||||
// ── Mode ──
|
||||
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
|
@ -55,12 +59,15 @@ export const useStore = create((set, get) => ({
|
|||
clearRoute: () => set({ route: null, routeError: null }),
|
||||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
|
||||
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null }),
|
||||
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
|
||||
setClickMarker: (marker) => set({ clickMarker: marker }),
|
||||
clearClickMarker: () => set({ clickMarker: null }),
|
||||
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||
|
|
@ -78,6 +85,7 @@ 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: set pendingDestination only; origin-picker will add both
|
||||
set({ pendingDestination: place, selectedPlace: null })
|
||||
}
|
||||
},
|
||||
|
|
@ -112,3 +120,20 @@ export const useStore = create((set, get) => ({
|
|||
setEditingContact: (c) => set({ editingContact: c }),
|
||||
clearEditingContact: () => set({ editingContact: null }),
|
||||
}))
|
||||
|
||||
// ── Panel state selector ──
|
||||
// Returns string state, prioritizing preview to allow it alongside any route state
|
||||
export const usePanelState = () => {
|
||||
return useStore((s) => {
|
||||
const hasPreview = !!s.selectedPlace
|
||||
const hasRoute = !!s.route
|
||||
const hasStops = s.stops.length >= 1
|
||||
|
||||
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
|
||||
if (hasPreview && hasStops) return "PREVIEW_ROUTING"
|
||||
if (hasPreview) return "PREVIEW"
|
||||
if (hasRoute) return "ROUTE_CALCULATED"
|
||||
if (hasStops) return "ROUTING"
|
||||
return "IDLE"
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue