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 if (source === 'nickname') return if (type === 'coordinates') return if (type === 'locality' || type === 'city') return // POI subcategories from osm_value if available const osmVal = result.raw?.osm_value || '' if (osmVal.includes('cafe') || osmVal.includes('coffee')) return if (osmVal.includes('fuel') || osmVal.includes('gas')) return if (osmVal.includes('shop') || osmVal.includes('supermarket')) return if (osmVal.includes('hotel') || osmVal.includes('motel')) return return } 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) 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) 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 (
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 */}
{searchLoading ? (
) : query ? ( ) : null}
{/* Autocomplete dropdown */} {autocompleteOpen && results.length > 0 && (
    {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)} >
    {primary} {isContact && ( saved )} {r.match_code?.housenumber === 'matched' && ( exact )}
    {secondary && (
    {secondary}
    )}
  • ) })}
)}
) }) export default SearchBar