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(null) const [suggestions, setSuggestions] = useState([]) const [foundLocation, setFoundLocation] = useState(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(null) const [showSuggestions, setShowSuggestions] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) const debounceTimer = useRef(null) const inputRef = useRef(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) => { 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 ? (

🚧 We haven't loaded {uncoveredCityLabel || 'that area'} yet

Civic data for {uncoveredCityLabel || 'this location'} is on the way. In the meantime, explore one of our launch cities:

{LAUNCH_CITIES.map((c) => ( {c.city}, {c.state} ))}
) : null if (compact) { return (
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" /> {/* Autocomplete suggestions dropdown */} {showSuggestions && suggestions.length > 0 && (
{suggestions.map((suggestion, index) => { const addr = suggestion.address const locationName = addr.city || addr.town || addr.village || addr.county || 'Unknown' return ( ) })}
)}
{error && (

{error}

)} {coverageNotice}
) } return (
{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. */
{ 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} />

Just your ZIP — we'll find the city, county, and school district. Nothing stored.

{/* 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 && (

{zip} crosses jurisdiction lines — which is home?

{choices.map((c, i) => ( ))}
)}
) : ( /* 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. */
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 && (
{suggestions.map((suggestion, index) => { const addr = suggestion.address const locationName = addr.city || addr.town || addr.village || addr.county || 'Unknown' return ( ) })}
)}

{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"}

)} {/* Shared: use my current location + switch between ZIP and address. */}
{mode !== 'zip' && ( )} {mode !== 'place' && ( )} {mode !== 'address' && ( )}
{/* Error Message */} {error && (

{error}

)} {/* Coverage notice: place picked but not loaded yet (non-blocking). */} {coverageNotice} {/* Note: Suggestions now appear as autocomplete dropdown above */} {/* Location Results */} {foundLocation && !compact && (

Your Local Community

Select a jurisdiction level below to explore organizations, meeting minutes, and contacts:

{/* City */} {foundLocation.city && ( )} {/* County */} {foundLocation.county && ( )} {/* State */} {foundLocation.state && ( )} {/* School District */} {foundLocation.city && ( )}
{/* Action Buttons */}

Quick access to all local resources:

{/* Start Over */}
)}
) }