Skip to content

Commit 4ed3d81

Browse files
committed
Add map search functionality with Google Maps-inspired design
Implement comprehensive search for churches and places: - Remove zoom/pan navigation controls for cleaner map interface - Add Google Maps-inspired search bar (top-left, always visible) - Install Fuse.js for client-side fuzzy search - Create useChurchSearch hook for searching church names, addresses, regions, and descriptions - Create useGeocoding hook using Photon API for place name geocoding - Build SearchBar component with: - Real-time search as user types (300ms debounce) - Unified results showing both churches and geocoded places - Keyboard navigation (arrow keys, enter, escape) - Click outside to close - Church and place icons to distinguish result types - Add flyTo animations when selecting search results - Style with white background, shadow, rounded corners matching Google Maps aesthetic
1 parent c12e422 commit 4ed3d81

7 files changed

Lines changed: 494 additions & 13 deletions

File tree

package-lock.json

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@maplibre/maplibre-gl-geocoder": "^1.7.0",
2525
"class-variance-authority": "^0.7.1",
2626
"clsx": "^2.1.1",
27+
"fuse.js": "^7.1.0",
2728
"maplibre-gl": "^4.7.1",
2829
"react": "^19.2.3",
2930
"react-dom": "^19.2.3",

src/components/SearchBar.tsx

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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+
}

src/hooks/useChurchSearch.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useMemo } from 'react';
2+
import Fuse from 'fuse.js';
3+
4+
interface ChurchProperties {
5+
name: string;
6+
address: string;
7+
region: string;
8+
website?: string;
9+
note?: string;
10+
}
11+
12+
interface ChurchFeature {
13+
type: 'Feature';
14+
geometry: {
15+
type: 'Point';
16+
coordinates: [number, number];
17+
};
18+
properties: ChurchProperties;
19+
}
20+
21+
export function useChurchSearch(churches: ChurchFeature[]) {
22+
const fuse = useMemo(() => {
23+
return new Fuse(churches, {
24+
keys: [
25+
{ name: 'properties.name', weight: 2 },
26+
{ name: 'properties.address', weight: 1.5 },
27+
{ name: 'properties.region', weight: 1 },
28+
{ name: 'properties.note', weight: 0.5 }
29+
],
30+
threshold: 0.4,
31+
includeScore: true,
32+
minMatchCharLength: 2,
33+
ignoreLocation: true
34+
});
35+
}, [churches]);
36+
37+
const search = (query: string) => {
38+
if (!query || query.length < 2) return [];
39+
return fuse.search(query).slice(0, 10);
40+
};
41+
42+
return { search };
43+
}

src/hooks/useGeocoding.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useState, useCallback } from 'react';
2+
3+
interface GeocodingFeature {
4+
type: 'Feature';
5+
geometry: {
6+
type: 'Point';
7+
coordinates: [number, number];
8+
};
9+
properties: {
10+
name: string;
11+
country?: string;
12+
state?: string;
13+
city?: string;
14+
};
15+
}
16+
17+
export function useGeocoding() {
18+
const [isLoading, setIsLoading] = useState(false);
19+
20+
const geocode = useCallback(async (query: string): Promise<GeocodingFeature[]> => {
21+
if (!query || query.length < 3) return [];
22+
23+
setIsLoading(true);
24+
try {
25+
const response = await fetch(
26+
`https://photon.komoot.io/api/?q=${encodeURIComponent(query)}&limit=5`
27+
);
28+
const data = await response.json();
29+
return data.features || [];
30+
} catch (error) {
31+
console.error('Geocoding error:', error);
32+
return [];
33+
} finally {
34+
setIsLoading(false);
35+
}
36+
}, []);
37+
38+
return { geocode, isLoading };
39+
}

0 commit comments

Comments
 (0)