|
| 1 | +import { useState, useEffect, useRef } from 'react'; |
| 2 | +import { useChurchSearch } from '../hooks/useChurchSearch'; |
| 3 | +import { useGeocoding } from '../hooks/useGeocoding'; |
| 4 | + |
| 5 | +interface ChurchProperties { |
| 6 | + name: string; |
| 7 | + address: string; |
| 8 | + region: string; |
| 9 | + website?: string; |
| 10 | + note?: string; |
| 11 | +} |
| 12 | + |
| 13 | +interface ChurchFeature { |
| 14 | + type: 'Feature'; |
| 15 | + geometry: { |
| 16 | + type: 'Point'; |
| 17 | + coordinates: [number, number]; |
| 18 | + }; |
| 19 | + properties: ChurchProperties; |
| 20 | +} |
| 21 | + |
| 22 | +interface SearchBarProps { |
| 23 | + churches: ChurchFeature[]; |
| 24 | + onSelectChurch: (church: ChurchFeature) => void; |
| 25 | + onSelectPlace: (coordinates: [number, number], name: string) => void; |
| 26 | +} |
| 27 | + |
| 28 | +interface SearchResult { |
| 29 | + type: 'church' | 'place'; |
| 30 | + label: string; |
| 31 | + sublabel: string; |
| 32 | + coordinates: [number, number]; |
| 33 | + data: any; |
| 34 | +} |
| 35 | + |
| 36 | +export default function SearchBar({ churches, onSelectChurch, onSelectPlace }: SearchBarProps) { |
| 37 | + const [query, setQuery] = useState(''); |
| 38 | + const [isOpen, setIsOpen] = useState(false); |
| 39 | + const [results, setResults] = useState<SearchResult[]>([]); |
| 40 | + const [selectedIndex, setSelectedIndex] = useState(-1); |
| 41 | + const searchRef = useRef<HTMLDivElement>(null); |
| 42 | + const inputRef = useRef<HTMLInputElement>(null); |
| 43 | + |
| 44 | + const { search: searchChurches } = useChurchSearch(churches); |
| 45 | + const { geocode } = useGeocoding(); |
| 46 | + |
| 47 | + // Handle search |
| 48 | + useEffect(() => { |
| 49 | + const performSearch = async () => { |
| 50 | + if (query.length < 2) { |
| 51 | + setResults([]); |
| 52 | + setIsOpen(false); |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + // Search churches locally |
| 57 | + const churchResults = searchChurches(query); |
| 58 | + |
| 59 | + // Geocode for places (with debounce) |
| 60 | + const placeResults = await geocode(query); |
| 61 | + |
| 62 | + // Calculate distances for places (optional enhancement) |
| 63 | + const combinedResults: SearchResult[] = [ |
| 64 | + ...churchResults.map(r => ({ |
| 65 | + type: 'church' as const, |
| 66 | + label: r.item.properties.name, |
| 67 | + sublabel: r.item.properties.address, |
| 68 | + coordinates: r.item.geometry.coordinates as [number, number], |
| 69 | + data: r.item |
| 70 | + })), |
| 71 | + ...placeResults.map(r => ({ |
| 72 | + type: 'place' as const, |
| 73 | + label: r.properties.name, |
| 74 | + sublabel: [r.properties.city, r.properties.state, r.properties.country] |
| 75 | + .filter(Boolean) |
| 76 | + .join(', ') || 'Place', |
| 77 | + coordinates: r.geometry.coordinates as [number, number], |
| 78 | + data: r |
| 79 | + })) |
| 80 | + ]; |
| 81 | + |
| 82 | + setResults(combinedResults); |
| 83 | + setIsOpen(combinedResults.length > 0); |
| 84 | + setSelectedIndex(-1); |
| 85 | + }; |
| 86 | + |
| 87 | + const timer = setTimeout(performSearch, 300); |
| 88 | + return () => clearTimeout(timer); |
| 89 | + }, [query, searchChurches, geocode]); |
| 90 | + |
| 91 | + // Click outside to close |
| 92 | + useEffect(() => { |
| 93 | + const handleClickOutside = (event: MouseEvent) => { |
| 94 | + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { |
| 95 | + setIsOpen(false); |
| 96 | + } |
| 97 | + }; |
| 98 | + |
| 99 | + document.addEventListener('mousedown', handleClickOutside); |
| 100 | + return () => document.removeEventListener('mousedown', handleClickOutside); |
| 101 | + }, []); |
| 102 | + |
| 103 | + // Keyboard navigation |
| 104 | + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { |
| 105 | + if (!isOpen || results.length === 0) return; |
| 106 | + |
| 107 | + switch (e.key) { |
| 108 | + case 'ArrowDown': |
| 109 | + e.preventDefault(); |
| 110 | + setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev)); |
| 111 | + break; |
| 112 | + case 'ArrowUp': |
| 113 | + e.preventDefault(); |
| 114 | + setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1)); |
| 115 | + break; |
| 116 | + case 'Enter': |
| 117 | + e.preventDefault(); |
| 118 | + if (selectedIndex >= 0 && selectedIndex < results.length) { |
| 119 | + handleSelectResult(results[selectedIndex]); |
| 120 | + } |
| 121 | + break; |
| 122 | + case 'Escape': |
| 123 | + setIsOpen(false); |
| 124 | + inputRef.current?.blur(); |
| 125 | + break; |
| 126 | + } |
| 127 | + }; |
| 128 | + |
| 129 | + const handleSelectResult = (result: SearchResult) => { |
| 130 | + if (result.type === 'church') { |
| 131 | + onSelectChurch(result.data); |
| 132 | + } else { |
| 133 | + onSelectPlace(result.coordinates, result.label); |
| 134 | + } |
| 135 | + setQuery(''); |
| 136 | + setIsOpen(false); |
| 137 | + inputRef.current?.blur(); |
| 138 | + }; |
| 139 | + |
| 140 | + return ( |
| 141 | + <div ref={searchRef} className="search-bar-container"> |
| 142 | + <div className="search-bar"> |
| 143 | + <svg |
| 144 | + className="search-icon" |
| 145 | + width="20" |
| 146 | + height="20" |
| 147 | + viewBox="0 0 24 24" |
| 148 | + fill="none" |
| 149 | + xmlns="http://www.w3.org/2000/svg" |
| 150 | + > |
| 151 | + <path |
| 152 | + d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" |
| 153 | + stroke="currentColor" |
| 154 | + strokeWidth="2" |
| 155 | + strokeLinecap="round" |
| 156 | + strokeLinejoin="round" |
| 157 | + /> |
| 158 | + </svg> |
| 159 | + <input |
| 160 | + ref={inputRef} |
| 161 | + type="text" |
| 162 | + value={query} |
| 163 | + onChange={(e) => setQuery(e.target.value)} |
| 164 | + onFocus={() => query.length >= 2 && results.length > 0 && setIsOpen(true)} |
| 165 | + onKeyDown={handleKeyDown} |
| 166 | + placeholder="Search churches or places" |
| 167 | + className="search-input" |
| 168 | + /> |
| 169 | + {query && ( |
| 170 | + <button |
| 171 | + onClick={() => { |
| 172 | + setQuery(''); |
| 173 | + setIsOpen(false); |
| 174 | + inputRef.current?.focus(); |
| 175 | + }} |
| 176 | + className="search-clear" |
| 177 | + aria-label="Clear search" |
| 178 | + > |
| 179 | + <svg width="20" height="20" viewBox="0 0 24 24" fill="none"> |
| 180 | + <path |
| 181 | + d="M18 6L6 18M6 6L18 18" |
| 182 | + stroke="currentColor" |
| 183 | + strokeWidth="2" |
| 184 | + strokeLinecap="round" |
| 185 | + strokeLinejoin="round" |
| 186 | + /> |
| 187 | + </svg> |
| 188 | + </button> |
| 189 | + )} |
| 190 | + </div> |
| 191 | + |
| 192 | + {isOpen && results.length > 0 && ( |
| 193 | + <div className="search-dropdown"> |
| 194 | + {results.map((result, idx) => ( |
| 195 | + <button |
| 196 | + key={idx} |
| 197 | + className={`search-result-item ${idx === selectedIndex ? 'selected' : ''}`} |
| 198 | + onClick={() => handleSelectResult(result)} |
| 199 | + onMouseEnter={() => setSelectedIndex(idx)} |
| 200 | + > |
| 201 | + <div className="search-result-icon"> |
| 202 | + {result.type === 'church' ? ( |
| 203 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none"> |
| 204 | + <path |
| 205 | + d="M12 2L12 6M12 6L8 6M12 6L16 6M6 10L18 10M9 10L9 22M15 10L15 22M6 22L18 22M4 10L4 22M20 10L20 22" |
| 206 | + stroke="currentColor" |
| 207 | + strokeWidth="2" |
| 208 | + strokeLinecap="round" |
| 209 | + strokeLinejoin="round" |
| 210 | + /> |
| 211 | + </svg> |
| 212 | + ) : ( |
| 213 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none"> |
| 214 | + <path |
| 215 | + d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" |
| 216 | + fill="currentColor" |
| 217 | + /> |
| 218 | + </svg> |
| 219 | + )} |
| 220 | + </div> |
| 221 | + <div className="search-result-text"> |
| 222 | + <div className="search-result-label">{result.label}</div> |
| 223 | + <div className="search-result-sublabel">{result.sublabel}</div> |
| 224 | + </div> |
| 225 | + </button> |
| 226 | + ))} |
| 227 | + </div> |
| 228 | + )} |
| 229 | + </div> |
| 230 | + ); |
| 231 | +} |
0 commit comments