Navi
diff --git a/src/components/PlaceDetail.jsx b/src/components/PlaceDetail.jsx
index 19f6f46..18760a8 100644
--- a/src/components/PlaceDetail.jsx
+++ b/src/components/PlaceDetail.jsx
@@ -1,25 +1,187 @@
import { useEffect, useState, useRef, useCallback } from 'react'
-import { X, Navigation, Plus, Bookmark, ChevronDown, Copy } from 'lucide-react'
+import {
+ X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
+ Clock, Phone, Globe, Mail, BookOpen, Info,
+} from 'lucide-react'
+import OpeningHours from 'opening_hours'
import toast from 'react-hot-toast'
import { useStore } from '../store'
-import { fetchElevation } from '../api'
+import { fetchElevation, fetchPlaceDetails, fetchDriveTime, fetchNearbyContacts } from '../api'
+import { hasFeature } from '../config'
+import { buildAddress } from '../utils/place'
/** Meters to feet */
const M_TO_FT = 3.28084
-/** Build display address from raw result data */
-function buildAddress(place) {
- if (place.address) return place.address
- const raw = place.raw || {}
- const parts = [raw.street, raw.city, raw.state, raw.postcode].filter(Boolean)
- return parts.join(', ') || null
+/** 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`
}
-/** Copy popover — small dropdown beneath the Copy button */
+// ── 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 (
+
+
+ {Icon && }
+ {label}
+
+ {children}
+
+ )
+}
+
+// ── Hours display ──────────────────────────────────────────────────────
+
+function HoursDisplay({ hoursStr, first }) {
+ const [expanded, setExpanded] = useState(false)
+ const parsed = parseHours(hoursStr)
+
+ if (!parsed) {
+ return (
+
+ {hoursStr}
+
+ )
+ }
+
+ return (
+
+
+ {expanded && (
+
+ {parsed.week.map((d) => (
+
+ {d.day}
+ {d.hours}
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ── Copy popover ───────────────────────────────────────────────────────
+
function CopyPopover({ address, selectedPlace, onClose }) {
const ref = useRef(null)
- // Close on click-outside
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) onClose()
@@ -86,6 +248,124 @@ function CopyPopover({ address, selectedPlace, onClose }) {
)
}
+// ── 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 (
+
+ {hasAbout && (
+
+ {category}
+
+ )}
+
+ {hasHours &&
}
+
+ {hasContact && (
+
+
+
+ )}
+
+ {hasDetails && (
+
+
+ {et.cuisine && Cuisine: {et.cuisine.replace(/_/g, ' ').replace(/;/g, ', ')}}
+ {et.operator && Operated by {et.operator}}
+ {et.fee && {et.fee === 'no' ? 'Free' : `Fee: ${et.fee}`}}
+ {et.wheelchair && wheelchairLabel(et.wheelchair) && {wheelchairLabel(et.wheelchair)}}
+ {et.takeaway === 'yes' && Takeaway available}
+
+
+ )}
+
+ {hasLinks && (
+
+
+
+ )}
+
+ )
+}
+
+// ── Skeleton loader ────────────────────────────────────────────────────
+
+function EnrichmentSkeleton() {
+ return (
+
+ )
+}
+
+// ── Main component ─────────────────────────────────────────────────────
+
export default function PlaceDetail() {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
@@ -93,12 +373,18 @@ export default function PlaceDetail() {
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 [copyForPlace, setCopyForPlace] = useState(null)
+ const [copyOpen, setCopyOpen] = useState(false)
+ const [placeDetails, setPlaceDetails] = useState(null)
+ const [driveTime, setDriveTime] = useState(null)
+ const [nearbyLabel, setNearbyLabel] = useState(null)
- const closeCopy = useCallback(() => setCopyForPlace(null), [])
+ const closeCopy = useCallback(() => setCopyOpen(false), [])
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
@@ -107,6 +393,8 @@ export default function PlaceDetail() {
return () => window.removeEventListener('resize', check)
}, [])
+ // Close copy popover when place changes
+ useEffect(() => { setCopyOpen(false) }, [selectedPlace])
// Escape key closes panel
useEffect(() => {
@@ -130,22 +418,96 @@ export default function PlaceDetail() {
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 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])
+
// 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
- const placeKey = selectedPlace ? `${selectedPlace.lat},${selectedPlace.lon}` : null
if (!selectedPlace) return null
const address = buildAddress(selectedPlace)
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
- const raw = selectedPlace.raw || {}
// 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) {
@@ -165,7 +527,25 @@ export default function PlaceDetail() {
}
const handleSave = () => {
- toast('Saved places coming soon')
+ 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 = (
@@ -183,13 +563,23 @@ export default function PlaceDetail() {
{/* Place name */}
- {selectedPlace.name}
+ {selectedPlace.type === 'poi' && selectedPlace.raw?.name
+ ? selectedPlace.raw.name
+ : selectedPlace.name}
- {selectedPlace.type && (
-
- {selectedPlace.type}
-
- )}
+ {(() => {
+ 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 (
+
+ {parts.join(' \u00b7 ')}
+
+ )
+ })()}
{/* Address */}
@@ -208,24 +598,9 @@ export default function PlaceDetail() {
- {/* Optional extras */}
- {(raw.opening_hours || raw.website || raw.phone) && (
-
@@ -259,16 +634,20 @@ export default function PlaceDetail() {
{/* Copy dropdown */}
- {copyForPlace === placeKey && (
+ {copyOpen && (
{panelContent}
diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx
index 652b216..732c865 100644
--- a/src/components/SearchBar.jsx
+++ b/src/components/SearchBar.jsx
@@ -1,8 +1,10 @@
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
-import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X } from 'lucide-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 }) {
@@ -10,6 +12,7 @@ function CategoryIcon({ result }) {
const source = result.source || ''
const size = 14
+ if (result._isContact) return
if (source === 'nickname') return
if (type === 'coordinates') return
if (type === 'locality' || type === 'city') return
@@ -39,6 +42,7 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
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)
@@ -46,6 +50,7 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
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)
useEffect(() => {
@@ -64,25 +69,56 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
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)
- setResults(data.results || [])
- setAutocompleteOpen(data.results?.length > 0)
+ const combined = [...contactResults, ...(data.results || [])]
+ setResults(combined)
+ setAutocompleteOpen(combined.length > 0)
setActiveIndex(-1)
} catch (e) {
if (e.name !== 'AbortError') {
- setResults([])
- setAutocompleteOpen(false)
+ // 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]
+ [setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
)
const handleChange = (e) => {
@@ -102,14 +138,22 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
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) {
- // GPS-denied Directions flow: this result becomes the starting point
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 {
- // Normal flow: open PlaceDetail
setSelectedPlace({
lat: result.lat,
lon: result.lon,
@@ -209,27 +253,45 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
}}
role="listbox"
>
- {results.map((r, i) => (
+ {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 (
selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
-
+
- {r.name}
+ {primary}
+ {isContact && (
+
+ saved
+
+ )}
{r.match_code?.housenumber === 'matched' && (
-
- {r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
-
+ {secondary && (
+
+ {secondary}
+
+ )}
- ))}
+ )
+ })}
)}
diff --git a/src/config.js b/src/config.js
index ce0e0a8..87496fe 100644
--- a/src/config.js
+++ b/src/config.js
@@ -29,6 +29,7 @@ const FALLBACK_CONFIG = {
has_traffic_overlay: false,
has_landclass: false,
has_address_book_write: false,
+ has_contacts: false,
},
defaults: {
center: [42.5736, -114.6066],
diff --git a/src/index.css b/src/index.css
index 4f8eced..8fca9fe 100644
--- a/src/index.css
+++ b/src/index.css
@@ -469,3 +469,69 @@ body {
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);
+}
diff --git a/src/store.js b/src/store.js
index 3ea3733..1dec344 100644
--- a/src/store.js
+++ b/src/store.js
@@ -101,4 +101,14 @@ export const useStore = create((set, get) => ({
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 }),
}))