import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react' import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X } from 'lucide-react' import toast from 'react-hot-toast' import { useStore } from '../store' import { searchGeocode } from '../api' /** Get category icon based on result type/source */ function CategoryIcon({ result }) { const type = result.type || '' const source = result.source || '' const size = 14 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 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 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 } 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) setActiveIndex(-1) } catch (e) { if (e.name !== 'AbortError') { setResults([]) setAutocompleteOpen(false) } } finally { setSearchLoading(false) } }, [setResults, setAutocompleteOpen, setSearchLoading, setAbortController] ) 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() 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, 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) => (
  • selectResult(r)} onMouseEnter={() => setActiveIndex(i)} >
    {r.name} {r.match_code?.housenumber === 'matched' && ( exact )}
    {r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
  • ))}
)}
) }) export default SearchBar