Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useState, useEffect, useRef } from 'react' | |
| import { Link } from 'react-router-dom' | |
| import { MapPinIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline' | |
| import { nominatimUsStateCode } from '../utils/stateMapping' | |
| import { resolveZipToChoices } from '../utils/resolvePlace' | |
| import { useLocation as useLocationContext, type LocationData, type LocationGranularity } from '../contexts/LocationContext' | |
| import { LAUNCH_CITIES, isLocationCovered } from '../lib/launchCoverage' | |
| interface AddressLookupProps { | |
| onLocationFound: (location: LocationData) => void | |
| /** | |
| * Called instead of onLocationFound when the resolved place isn't one of our | |
| * launch cities. Provide this to take full control of the "not loaded yet" UX | |
| * (e.g. block the selection and show a custom notice). When omitted, | |
| * AddressLookup still applies the location but shows a built-in, non-blocking | |
| * "not loaded yet" notice with the launch cities as alternatives. | |
| */ | |
| onUncovered?: (location: LocationData) => void | |
| initialAddress?: string | |
| compact?: boolean | |
| } | |
| // In "city or county" mode we only want real places โ cities, towns, villages, | |
| // counties, states โ not the businesses, hotels and street addresses Nominatim | |
| // otherwise ranks highly for a query like "Atlanta, GA". A POI like "TownePlace | |
| // Suites Atlanta Airport North" comes back as class=tourism/building; an actual | |
| // place comes back as an admin boundary or a place node. Keep only the latter. | |
| const PLACE_ADDRESS_TYPES = new Set([ | |
| 'city', 'town', 'village', 'hamlet', 'municipality', 'borough', | |
| 'suburb', 'county', 'state', 'administrative', | |
| ]) | |
| function isPlaceLikeResult(r: any): boolean { | |
| if (r?.class === 'boundary' && r?.type === 'administrative') return true | |
| if (r?.class === 'place' && PLACE_ADDRESS_TYPES.has(r?.addresstype)) return true | |
| return false | |
| } | |
| // A short, human label ("Atlanta, GA" / "Fulton County, GA") for the input box | |
| // after a place is picked โ far nicer than the full Nominatim display_name. | |
| function placeLabel(r: any): string { | |
| const addr = r?.address || {} | |
| const stateCode = nominatimUsStateCode(addr) || '' | |
| const name = | |
| addr.city || addr.town || addr.village || addr.municipality || | |
| addr.hamlet || addr.suburb || addr.county || addr.state || '' | |
| if (name && stateCode) return `${name}, ${stateCode}` | |
| return r?.display_name || name || '' | |
| } | |
| export default function AddressLookup({ onLocationFound, onUncovered, initialAddress = '', compact = false }: AddressLookupProps) { | |
| const { clearLocation } = useLocationContext() | |
| // Default lookup mode is ZIP/postal code (mirrors the MoneyGameModal "where's | |
| // home?" gate). For people who don't know their ZIP, 'place' searches by city | |
| // or county name, and 'address' is the optional full-address search. The | |
| // 'place' and 'address' modes share the same autocomplete + processResult | |
| // machinery (Nominatim resolves both to a real LocationData) โ they differ | |
| // only in the copy that frames what to type. | |
| const [mode, setMode] = useState<'zip' | 'place' | 'address'>('zip') | |
| const [zip, setZip] = useState('') | |
| // When a ZIP spans cities / inside-vs-outside city limits, the user picks which | |
| // real place is home. Built from real geocode results (resolvePlace.buildZipChoices). | |
| const [choices, setChoices] = useState<{ label: string; loc: LocationData }[] | null>(null) | |
| const [address, setAddress] = useState(initialAddress) | |
| const [isLoading, setIsLoading] = useState(false) | |
| const [error, setError] = useState<string | null>(null) | |
| const [suggestions, setSuggestions] = useState<any[]>([]) | |
| const [foundLocation, setFoundLocation] = useState<LocationData | null>(null) | |
| // The picked place when it isn't a launch city โ drives the built-in "not | |
| // loaded yet" notice (only used when the caller didn't supply onUncovered). | |
| const [uncovered, setUncovered] = useState<LocationData | null>(null) | |
| const [showSuggestions, setShowSuggestions] = useState(false) | |
| const [selectedIndex, setSelectedIndex] = useState(-1) | |
| const debounceTimer = useRef<number | null>(null) | |
| const inputRef = useRef<HTMLInputElement>(null) | |
| const zipValid = /^\d{5}$/.test(zip) | |
| const needsChoice = !!choices && choices.length > 1 | |
| // Single commit point: record the resolved place and notify the parent. Used by | |
| // the ZIP path, the address path, and "use my location" so they stay consistent. | |
| // Coverage gate: if the place isn't a launch city, either hand off to the | |
| // caller's onUncovered (full control) or apply it but show a built-in, | |
| // non-blocking "not loaded yet" notice. We never silently pretend data exists. | |
| const commitLocation = (locationData: LocationData) => { | |
| setSuggestions([]) | |
| setShowSuggestions(false) | |
| setChoices(null) | |
| setError(null) | |
| if (!isLocationCovered(locationData)) { | |
| if (onUncovered) { | |
| onUncovered(locationData) | |
| return | |
| } | |
| setUncovered(locationData) | |
| setFoundLocation(locationData) | |
| onLocationFound(locationData) | |
| return | |
| } | |
| setUncovered(null) | |
| setFoundLocation(locationData) | |
| onLocationFound(locationData) | |
| } | |
| // Resolve the entered ZIP to its real place choices. 0 โ error; 1 โ commit; | |
| // >1 (ZIP crosses jurisdictions / city limits) โ let the user specify. | |
| const resolveZip = async () => { | |
| if (!zipValid) { | |
| setError('Please enter a 5-digit ZIP code.') | |
| return | |
| } | |
| setIsLoading(true) | |
| setError(null) | |
| setChoices(null) | |
| try { | |
| const opts = await resolveZipToChoices(zip) | |
| if (opts.length === 0) { | |
| setError("We couldn't find that ZIP. Try another, or search by address.") | |
| } else if (opts.length === 1) { | |
| commitLocation(opts[0].loc) | |
| } else { | |
| setChoices(opts) | |
| } | |
| } catch (err) { | |
| console.error('ZIP lookup error:', err) | |
| setError("We couldn't look up that ZIP right now. Please try again.") | |
| } finally { | |
| setIsLoading(false) | |
| } | |
| } | |
| const handleZipSubmit = (e: React.FormEvent) => { | |
| e.preventDefault() | |
| if (isLoading || needsChoice) return | |
| void resolveZip() | |
| } | |
| const switchMode = (next: 'zip' | 'place' | 'address') => { | |
| setMode(next) | |
| setError(null) | |
| setChoices(null) | |
| setSuggestions([]) | |
| setShowSuggestions(false) | |
| setUncovered(null) | |
| } | |
| // Fetch suggestions as user types | |
| const fetchSuggestions = async (query: string) => { | |
| if (query.trim().length < 3) { | |
| setSuggestions([]) | |
| setShowSuggestions(false) | |
| return | |
| } | |
| try { | |
| // Same-origin proxy (api/routes/geocode.py): avoids Nominatim CORS and | |
| // honors its rate-limit policy server-side. In place mode we ask for more | |
| // rows than we show, because we filter POIs/streets out below. | |
| const limit = mode === 'place' ? 12 : 5 | |
| const response = await fetch( | |
| `/api/geocode/search?q=${encodeURIComponent(query)}&limit=${limit}`, | |
| ) | |
| if (!response.ok) { | |
| return | |
| } | |
| const data = await response.json() | |
| // Deduplicate results using OSM unique IDs | |
| const uniqueResults = data.reduce((acc: any[], current: any) => { | |
| const osmKey = `${current.osm_type}_${current.osm_id}` | |
| const exists = acc.some((item) => { | |
| const itemKey = `${item.osm_type}_${item.osm_id}` | |
| return itemKey === osmKey | |
| }) | |
| if (!exists) { | |
| acc.push(current) | |
| } | |
| return acc | |
| }, []) | |
| // City/county search: drop businesses & street addresses so the user can | |
| // actually pick "Atlanta, GA". Fall back to the raw list only if filtering | |
| // leaves nothing (so we never show an empty dropdown for a real match). | |
| let finalResults = uniqueResults | |
| if (mode === 'place') { | |
| const placesOnly = uniqueResults.filter(isPlaceLikeResult) | |
| finalResults = (placesOnly.length > 0 ? placesOnly : uniqueResults).slice(0, 6) | |
| } | |
| setSuggestions(finalResults) | |
| setShowSuggestions(finalResults.length > 0) | |
| setSelectedIndex(-1) | |
| } catch (err) { | |
| console.error('Autocomplete error:', err) | |
| } | |
| } | |
| // Handle address input change with debouncing | |
| const handleAddressChange = (value: string) => { | |
| setAddress(value) | |
| setError(null) | |
| // Clear previous timer | |
| if (debounceTimer.current) { | |
| clearTimeout(debounceTimer.current) | |
| } | |
| // Set new timer | |
| debounceTimer.current = setTimeout(() => { | |
| fetchSuggestions(value) | |
| }, 300) | |
| } | |
| // Cleanup timer on unmount | |
| useEffect(() => { | |
| return () => { | |
| if (debounceTimer.current) { | |
| clearTimeout(debounceTimer.current) | |
| } | |
| } | |
| }, []) | |
| const lookupAddress = async (addressToLookup: string) => { | |
| if (!addressToLookup.trim()) { | |
| setError('Please enter an address') | |
| return | |
| } | |
| setIsLoading(true) | |
| setError(null) | |
| setSuggestions([]) | |
| setShowSuggestions(false) | |
| try { | |
| // Same-origin proxy (api/routes/geocode.py): avoids Nominatim CORS and | |
| // honors its rate-limit policy server-side. In place mode over-fetch then | |
| // filter to real places (see fetchSuggestions). | |
| const limit = mode === 'place' ? 12 : 5 | |
| const response = await fetch( | |
| `/api/geocode/search?q=${encodeURIComponent(addressToLookup)}&limit=${limit}`, | |
| ) | |
| if (!response.ok) { | |
| throw new Error('Failed to lookup address') | |
| } | |
| const data = await response.json() | |
| if (data.length === 0) { | |
| setError('Address not found. Please try a different address or be more specific.') | |
| return | |
| } | |
| // Deduplicate results using OSM unique IDs | |
| let uniqueResults = data.reduce((acc: any[], current: any) => { | |
| // Use OSM type + ID as unique key (most reliable) | |
| const osmKey = `${current.osm_type}_${current.osm_id}` | |
| const exists = acc.some((item) => { | |
| const itemKey = `${item.osm_type}_${item.osm_id}` | |
| return itemKey === osmKey | |
| }) | |
| if (!exists) { | |
| acc.push(current) | |
| } | |
| return acc | |
| }, []) | |
| // City/county search: keep only real places (drop POIs/street addresses). | |
| if (mode === 'place') { | |
| const placesOnly = uniqueResults.filter(isPlaceLikeResult) | |
| uniqueResults = (placesOnly.length > 0 ? placesOnly : uniqueResults).slice(0, 6) | |
| } | |
| if (uniqueResults.length === 0) { | |
| setError('Address not found. Please try a different address or be more specific.') | |
| return | |
| } | |
| // If we have multiple unique results, show suggestions | |
| if (uniqueResults.length > 1) { | |
| setSuggestions(uniqueResults) | |
| setShowSuggestions(true) | |
| return | |
| } | |
| // Single result - process it | |
| processResult(uniqueResults[0]) | |
| } catch (err) { | |
| console.error('Address lookup error:', err) | |
| setError('Failed to lookup address. Please try again.') | |
| } finally { | |
| setIsLoading(false) | |
| } | |
| } | |
| const processResult = (result: any) => { | |
| const addr = result.address || {} | |
| const stateCode = nominatimUsStateCode(addr) || '' | |
| console.log(`๐บ๏ธ [AddressLookup] State from Nominatim address: โ "${stateCode}"`) | |
| const county = (addr.county as string) || '' | |
| const city = | |
| (addr.city as string) || | |
| (addr.town as string) || | |
| (addr.village as string) || | |
| (addr.municipality as string) || | |
| (addr.hamlet as string) || | |
| (addr.suburb as string) || | |
| '' | |
| const hasMunicipality = Boolean(city.trim()) | |
| const hasCounty = Boolean(county.trim()) | |
| let granularity: LocationGranularity | undefined | |
| if (!hasMunicipality) { | |
| if (hasCounty) { | |
| granularity = 'county' | |
| } else if ( | |
| result.addresstype === 'state' || | |
| (stateCode && | |
| result.class === 'boundary' && | |
| result.type === 'administrative' && | |
| result.addresstype !== 'county') | |
| ) { | |
| granularity = 'state' | |
| } | |
| } | |
| if (!stateCode) { | |
| setError('Could not determine a U.S. state from this place. Try a street address or pick a suggestion from the list.') | |
| setSuggestions([]) | |
| setShowSuggestions(false) | |
| return | |
| } | |
| if (!hasMunicipality && !granularity) { | |
| setError( | |
| 'Could not determine a city or county from this place. Try a street address, city name, or pick a more specific suggestion.' | |
| ) | |
| setSuggestions([]) | |
| setShowSuggestions(false) | |
| return | |
| } | |
| const locationData: LocationData = { | |
| address: result.display_name, | |
| state: stateCode, | |
| county, | |
| city, | |
| granularity, | |
| latitude: parseFloat(result.lat), | |
| longitude: parseFloat(result.lon), | |
| } | |
| console.log('๐ [AddressLookup] Location found:', locationData) | |
| commitLocation(locationData) | |
| } | |
| const handleSubmit = (e: React.FormEvent) => { | |
| e.preventDefault() | |
| // If a suggestion is selected, use that | |
| if (selectedIndex >= 0 && suggestions[selectedIndex]) { | |
| processResult(suggestions[selectedIndex]) | |
| } else { | |
| lookupAddress(address) | |
| } | |
| } | |
| const handleSuggestionClick = (suggestion: any) => { | |
| // In city/county mode show the short "Atlanta, GA" label; full address mode | |
| // keeps the complete display_name. | |
| setAddress(mode === 'place' ? placeLabel(suggestion) : suggestion.display_name) | |
| processResult(suggestion) | |
| } | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |
| if (!showSuggestions || suggestions.length === 0) return | |
| switch (e.key) { | |
| case 'ArrowDown': | |
| e.preventDefault() | |
| setSelectedIndex(prev => | |
| prev < suggestions.length - 1 ? prev + 1 : prev | |
| ) | |
| break | |
| case 'ArrowUp': | |
| e.preventDefault() | |
| setSelectedIndex(prev => prev > 0 ? prev - 1 : -1) | |
| break | |
| case 'Enter': | |
| if (selectedIndex >= 0) { | |
| e.preventDefault() | |
| processResult(suggestions[selectedIndex]) | |
| } | |
| break | |
| case 'Escape': | |
| setShowSuggestions(false) | |
| setSelectedIndex(-1) | |
| break | |
| } | |
| } | |
| const useMyLocation = () => { | |
| if (!navigator.geolocation) { | |
| setError('Geolocation is not supported by your browser') | |
| return | |
| } | |
| setIsLoading(true) | |
| setError(null) | |
| setSuggestions([]) | |
| navigator.geolocation.getCurrentPosition( | |
| async (position) => { | |
| const { latitude, longitude } = position.coords | |
| try { | |
| // Reverse geocode via same-origin proxy (api/routes/geocode.py). | |
| const response = await fetch( | |
| `/api/geocode/reverse?lat=${latitude}&lon=${longitude}`, | |
| ) | |
| if (!response.ok) { | |
| throw new Error('Failed to reverse geocode location') | |
| } | |
| const data = await response.json() | |
| // Update the address input field | |
| setAddress(data.display_name) | |
| // Process the result | |
| processResult(data) | |
| } catch (err) { | |
| console.error('Reverse geocoding error:', err) | |
| setError('Failed to determine your location. Please enter your address manually.') | |
| } finally { | |
| setIsLoading(false) | |
| } | |
| }, | |
| (error) => { | |
| console.error('Geolocation error:', error) | |
| setIsLoading(false) | |
| switch (error.code) { | |
| case error.PERMISSION_DENIED: | |
| setError('Location access denied. Please enter your address manually or enable location permissions.') | |
| break | |
| case error.POSITION_UNAVAILABLE: | |
| setError('Location information unavailable. Please enter your address manually.') | |
| break | |
| case error.TIMEOUT: | |
| setError('Location request timed out. Please try again or enter your address manually.') | |
| break | |
| default: | |
| setError('An error occurred while getting your location. Please enter your address manually.') | |
| } | |
| }, | |
| { | |
| enableHighAccuracy: false, // Use fast network-based location instead of GPS | |
| timeout: 15000, // 15 second timeout to allow time for user to grant permission | |
| maximumAge: 30000 // Allow 30s cached location for faster response | |
| } | |
| ) | |
| } | |
| // Built-in "not loaded yet" notice โ shown when the picked place isn't a launch | |
| // city and the caller didn't take over via onUncovered. Informational (the | |
| // location is still applied); points the user to the cities we have loaded. | |
| const uncoveredCityLabel = | |
| uncovered ? [uncovered.city, uncovered.state].filter(Boolean).join(', ') : '' | |
| const coverageNotice = | |
| uncovered && !onUncovered ? ( | |
| <div | |
| className="mt-4 rounded-lg border p-4" | |
| style={{ borderColor: '#fcd34d', background: '#fffbeb' }} | |
| role="status" | |
| > | |
| <p className="text-sm font-semibold" style={{ color: '#92400e' }}> | |
| ๐ง We haven't loaded {uncoveredCityLabel || 'that area'} yet | |
| </p> | |
| <p className="mt-1 text-sm" style={{ color: '#92400e' }}> | |
| Civic data for {uncoveredCityLabel || 'this location'} is on the way. In the | |
| meantime, explore one of our launch cities: | |
| </p> | |
| <div className="mt-3 flex flex-wrap gap-2"> | |
| {LAUNCH_CITIES.map((c) => ( | |
| <Link | |
| key={`${c.city}-${c.state}`} | |
| to={`/search?state=${c.state}`} | |
| className="rounded-full bg-white px-3 py-1.5 text-sm font-medium" | |
| style={{ border: '1px solid #fcd34d', color: '#92400e' }} | |
| > | |
| {c.city}, {c.state} | |
| </Link> | |
| ))} | |
| </div> | |
| </div> | |
| ) : null | |
| if (compact) { | |
| return ( | |
| <form onSubmit={handleSubmit} className="w-full"> | |
| <div className="relative"> | |
| <input | |
| key="address-input-compact" | |
| ref={inputRef} | |
| type="text" | |
| value={address} | |
| onChange={(e) => handleAddressChange(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Enter your address..." | |
| className="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900" | |
| disabled={isLoading} | |
| autoComplete="off" | |
| /> | |
| <MapPinIcon className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" /> | |
| <button | |
| type="submit" | |
| disabled={isLoading} | |
| className="absolute right-2 top-1.5 px-3 py-1 text-white rounded-md transition-colors text-sm disabled:opacity-50" | |
| style={{ backgroundColor: '#354F52' }} | |
| onMouseEnter={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#2e4346')} | |
| onMouseLeave={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#354F52')} | |
| > | |
| {isLoading ? 'Finding...' : 'Find'} | |
| </button> | |
| {/* Autocomplete suggestions dropdown */} | |
| {showSuggestions && suggestions.length > 0 && ( | |
| <div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"> | |
| {suggestions.map((suggestion, index) => { | |
| const addr = suggestion.address | |
| const locationName = addr.city || addr.town || addr.village || addr.county || 'Unknown' | |
| return ( | |
| <button | |
| key={`${suggestion.osm_type}_${suggestion.osm_id}`} | |
| type="button" | |
| onClick={() => handleSuggestionClick(suggestion)} | |
| className={`w-full px-4 py-2 text-left hover:bg-gray-100 transition-colors ${ | |
| index === selectedIndex ? 'bg-gray-100' : '' | |
| }`} | |
| > | |
| <p className="text-sm font-medium text-gray-900"> | |
| {suggestion.display_name} | |
| </p> | |
| <p className="text-xs text-gray-500"> | |
| {locationName}, {addr.state} | |
| </p> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {error && ( | |
| <p className="mt-2 text-sm text-red-600">{error}</p> | |
| )} | |
| {coverageNotice} | |
| </form> | |
| ) | |
| } | |
| return ( | |
| <div className="w-full"> | |
| {mode === 'zip' ? ( | |
| /* DEFAULT: ZIP / postal-code prompt (mirrors the money game's "where's | |
| home?" gate). Just the ZIP โ we resolve the real city/county/school | |
| district from it, and let the user specify when a ZIP spans places. */ | |
| <form onSubmit={handleZipSubmit} className="space-y-4"> | |
| <div> | |
| <label htmlFor="zip" className="block text-sm font-medium text-gray-700 mb-2"> | |
| <span className="flex items-center gap-2"> | |
| <MapPinIcon className="h-5 w-5" /> | |
| Enter Your ZIP Code | |
| </span> | |
| </label> | |
| <input | |
| key="zip-input" | |
| type="text" | |
| id="zip" | |
| name="zip" | |
| value={zip} | |
| onChange={(e) => { | |
| setZip(e.target.value.replace(/\D/g, '').slice(0, 5)) | |
| setError(null) | |
| setChoices(null) | |
| }} | |
| placeholder="e.g. 90001" | |
| inputMode="numeric" | |
| autoComplete="postal-code" | |
| className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-base text-gray-900 tracking-[0.12em]" | |
| disabled={isLoading} | |
| /> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| Just your ZIP โ we'll find the city, county, and school district. Nothing stored. | |
| </p> | |
| </div> | |
| {/* This ZIP spans cities / city limits โ let the user say where home is. | |
| City taxes & boards stack on the county's, so the choice matters. */} | |
| {needsChoice && choices && ( | |
| <div className="rounded-lg border border-amber-200 bg-amber-50 p-4"> | |
| <p className="text-sm font-medium text-amber-900"> | |
| {zip} crosses jurisdiction lines โ which is home? | |
| </p> | |
| <div className="mt-3 flex flex-wrap gap-2"> | |
| {choices.map((c, i) => ( | |
| <button | |
| key={c.label + i} | |
| type="button" | |
| onClick={() => commitLocation(c.loc)} | |
| className="rounded-full border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-900 transition-colors hover:border-primary-500 hover:bg-primary-50" | |
| > | |
| {c.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <button | |
| type="submit" | |
| disabled={isLoading || needsChoice || !zipValid} | |
| className="w-full px-6 py-3 text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" | |
| style={{ backgroundColor: '#354F52' }} | |
| onMouseEnter={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#2e4346')} | |
| onMouseLeave={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#354F52')} | |
| > | |
| {isLoading ? ( | |
| <> | |
| <div className="animate-spin h-5 w-5 border-2 border-white border-t-transparent rounded-full"></div> | |
| <span>Findingโฆ</span> | |
| </> | |
| ) : ( | |
| <> | |
| <MagnifyingGlassIcon className="h-5 w-5" /> | |
| <span>Find My Community</span> | |
| </> | |
| )} | |
| </button> | |
| </form> | |
| ) : ( | |
| /* For people who don't know their ZIP: 'place' searches by city or | |
| county name, 'address' is full-address search. Both use the same | |
| autocomplete + processResult path โ only the framing copy differs. */ | |
| <form onSubmit={handleSubmit} className="space-y-4"> | |
| <div> | |
| <label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-2"> | |
| <span className="flex items-center gap-2"> | |
| <MapPinIcon className="h-5 w-5" /> | |
| {mode === 'place' ? 'Enter Your City or County' : 'Enter Your Address'} | |
| </span> | |
| </label> | |
| <div className="relative"> | |
| <input | |
| key="address-input" | |
| ref={inputRef} | |
| type="text" | |
| id="address" | |
| name="addresslookup" | |
| value={address} | |
| onChange={(e) => handleAddressChange(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| mode === 'place' | |
| ? 'e.g. Tuscaloosa, or Tuscaloosa County, AL' | |
| : '123 Main St, Los Angeles, CA 90001' | |
| } | |
| className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-base text-gray-900" | |
| disabled={isLoading} | |
| autoComplete="off" | |
| /> | |
| {/* Autocomplete suggestions dropdown */} | |
| {showSuggestions && suggestions.length > 0 && ( | |
| <div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"> | |
| {suggestions.map((suggestion, index) => { | |
| const addr = suggestion.address | |
| const locationName = addr.city || addr.town || addr.village || addr.county || 'Unknown' | |
| return ( | |
| <button | |
| key={`${suggestion.osm_type}_${suggestion.osm_id}`} | |
| type="button" | |
| onClick={() => handleSuggestionClick(suggestion)} | |
| className={`w-full px-4 py-3 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 ${ | |
| index === selectedIndex ? 'bg-gray-100' : '' | |
| }`} | |
| > | |
| <p className="text-sm font-medium text-gray-900"> | |
| {suggestion.display_name} | |
| </p> | |
| <p className="text-xs text-gray-500 mt-1"> | |
| {locationName}, {addr.state} | |
| </p> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| {mode === 'place' | |
| ? "Don't know your ZIP? Type a city or county โ we'll find your community. Nothing stored." | |
| : "We'll find your local organizations based on your address"} | |
| </p> | |
| </div> | |
| <button | |
| type="submit" | |
| disabled={isLoading} | |
| className="w-full px-6 py-3 text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" | |
| style={{ backgroundColor: '#354F52' }} | |
| onMouseEnter={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#2e4346')} | |
| onMouseLeave={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#354F52')} | |
| > | |
| {isLoading ? ( | |
| <> | |
| <div className="animate-spin h-5 w-5 border-2 border-white border-t-transparent rounded-full"></div> | |
| <span>Looking up address...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <MagnifyingGlassIcon className="h-5 w-5" /> | |
| <span>Find My Community</span> | |
| </> | |
| )} | |
| </button> | |
| </form> | |
| )} | |
| {/* Shared: use my current location + switch between ZIP and address. */} | |
| <div className="mt-3"> | |
| <button | |
| type="button" | |
| onClick={useMyLocation} | |
| disabled={isLoading} | |
| className="w-full px-4 py-2 bg-white border-2 border-primary-300 text-primary-700 rounded-lg hover:bg-primary-50 hover:border-primary-500 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" | |
| > | |
| <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> | |
| </svg> | |
| <span>{foundLocation ? 'Change My Location' : 'Use My Current Location'}</span> | |
| </button> | |
| </div> | |
| <div className="mt-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-center"> | |
| {mode !== 'zip' && ( | |
| <button | |
| type="button" | |
| onClick={() => switchMode('zip')} | |
| className="text-sm text-primary-600 hover:text-primary-700 font-medium underline" | |
| > | |
| โ Use ZIP code instead | |
| </button> | |
| )} | |
| {mode !== 'place' && ( | |
| <button | |
| type="button" | |
| onClick={() => switchMode('place')} | |
| className="text-sm text-primary-600 hover:text-primary-700 font-medium underline" | |
| > | |
| Don't know your ZIP? Search by city or county | |
| </button> | |
| )} | |
| {mode !== 'address' && ( | |
| <button | |
| type="button" | |
| onClick={() => switchMode('address')} | |
| className="text-sm text-primary-600 hover:text-primary-700 font-medium underline" | |
| > | |
| Search by full address instead | |
| </button> | |
| )} | |
| </div> | |
| {/* Error Message */} | |
| {error && ( | |
| <div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg"> | |
| <p className="text-sm text-red-800">{error}</p> | |
| </div> | |
| )} | |
| {/* Coverage notice: place picked but not loaded yet (non-blocking). */} | |
| {coverageNotice} | |
| {/* Note: Suggestions now appear as autocomplete dropdown above */} | |
| {/* Location Results */} | |
| {foundLocation && !compact && ( | |
| <div className="mt-6 border-2 border-primary-200 rounded-lg overflow-hidden bg-primary-50"> | |
| <div className="bg-primary-600 px-4 py-3 flex items-center justify-between"> | |
| <h3 className="text-lg font-semibold text-white flex items-center gap-2"> | |
| <MapPinIcon className="h-5 w-5" /> | |
| Your Local Community | |
| </h3> | |
| <button | |
| onClick={() => window.location.href = '/'} | |
| className="text-sm text-white hover:text-primary-100 underline font-medium" | |
| > | |
| โ Back to Home | |
| </button> | |
| </div> | |
| <div className="p-6 space-y-4"> | |
| <p className="text-sm text-gray-700 mb-4"> | |
| Select a jurisdiction level below to explore organizations, meeting minutes, and contacts: | |
| </p> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {/* City */} | |
| {foundLocation.city && ( | |
| <button | |
| onClick={() => { | |
| window.location.href = `/?scope=city` | |
| }} | |
| className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-blue-500 transition-all text-left w-full group" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <div className="p-2 bg-blue-100 rounded-lg group-hover:bg-blue-200 transition-colors"> | |
| <svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /> | |
| </svg> | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">City</p> | |
| <p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-blue-600">{foundLocation.city}</p> | |
| <p className="text-sm text-gray-600 mt-1">City Council</p> | |
| <p className="text-xs text-blue-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| Click to explore โ | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| )} | |
| {/* County */} | |
| {foundLocation.county && ( | |
| <button | |
| onClick={() => { | |
| window.location.href = `/?scope=county` | |
| }} | |
| className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-green-500 transition-all text-left w-full group" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <div className="p-2 bg-green-100 rounded-lg group-hover:bg-green-200 transition-colors"> | |
| <svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /> | |
| </svg> | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">County</p> | |
| <p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-green-600">{foundLocation.county}</p> | |
| <p className="text-sm text-gray-600 mt-1">County Board</p> | |
| <p className="text-xs text-green-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| Click to explore โ | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| )} | |
| {/* State */} | |
| {foundLocation.state && ( | |
| <button | |
| onClick={() => { | |
| window.location.href = `/?scope=state` | |
| }} | |
| className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-purple-500 transition-all text-left w-full group" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <div className="p-2 bg-purple-100 rounded-lg group-hover:bg-purple-200 transition-colors"> | |
| <svg className="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" /> | |
| </svg> | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">State</p> | |
| <p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-purple-600">{foundLocation.state}</p> | |
| <p className="text-sm text-gray-600 mt-1">State Legislature</p> | |
| <p className="text-xs text-purple-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| Click to explore โ | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| )} | |
| {/* School District */} | |
| {foundLocation.city && ( | |
| <button | |
| onClick={() => { | |
| window.location.href = `/?scope=community` | |
| }} | |
| className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-amber-500 transition-all text-left w-full group" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <div className="p-2 bg-amber-100 rounded-lg group-hover:bg-amber-200 transition-colors"> | |
| <svg className="h-6 w-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> | |
| </svg> | |
| </div> | |
| <div className="flex-1"> | |
| <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">School District</p> | |
| <p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-amber-600">{foundLocation.city} Unified</p> | |
| <p className="text-sm text-gray-600 mt-1">School Board</p> | |
| <p className="text-xs text-amber-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| Click to explore โ | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| )} | |
| </div> | |
| {/* Action Buttons */} | |
| <div className="pt-4 border-t border-primary-200"> | |
| <p className="text-sm text-gray-600 mb-3">Quick access to all local resources:</p> | |
| <div className="flex flex-wrap gap-3"> | |
| <button | |
| onClick={() => { | |
| const cityQ = foundLocation.city | |
| ? `&city=${encodeURIComponent(foundLocation.city)}` | |
| : '' | |
| window.location.href = `/documents?state=${foundLocation.state}${cityQ}` | |
| }} | |
| className="flex-1 min-w-[200px] px-4 py-2 bg-white border-2 border-primary-600 text-primary-700 rounded-lg hover:bg-primary-50 transition-colors font-medium" | |
| > | |
| ๐ All Meeting Minutes | |
| </button> | |
| <button | |
| onClick={() => { | |
| const cityQ = foundLocation.city | |
| ? `&city=${encodeURIComponent(foundLocation.city)}` | |
| : '' | |
| window.location.href = `/nonprofits?state=${foundLocation.state}${cityQ}` | |
| }} | |
| className="flex-1 min-w-[200px] px-4 py-2 bg-white border-2 border-primary-600 text-primary-700 rounded-lg hover:bg-primary-50 transition-colors font-medium" | |
| > | |
| ๐ข All Local Organizations | |
| </button> | |
| </div> | |
| </div> | |
| {/* Start Over */} | |
| <div className="text-center pt-2"> | |
| <button | |
| onClick={() => { | |
| setFoundLocation(null) | |
| setAddress('') | |
| setZip('') | |
| setChoices(null) | |
| setMode('zip') | |
| setError(null) | |
| clearLocation() // Clear the global location context | |
| }} | |
| className="text-sm text-primary-600 hover:text-primary-700 font-medium underline" | |
| > | |
| Search Different Location | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |