open-navigator / frontend /src /components /AddressLookup.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
61d29fc
import { useState, useEffect, useRef } from 'react'
import { MapPinIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'
import { stateNameToCode } from '../utils/stateMapping'
import { useLocation as useLocationContext } from '../contexts/LocationContext'
interface LocationData {
address: string
state: string
county: string
city: string
latitude?: number
longitude?: number
}
interface AddressLookupProps {
onLocationFound: (location: LocationData) => void
initialAddress?: string
compact?: boolean
}
export default function AddressLookup({ onLocationFound, initialAddress = '', compact = false }: AddressLookupProps) {
const { clearLocation } = useLocationContext()
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)
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const debounceTimer = useRef<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Fetch suggestions as user types
const fetchSuggestions = async (query: string) => {
if (query.trim().length < 3) {
setSuggestions([])
setShowSuggestions(false)
return
}
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?` +
`q=${encodeURIComponent(query)}&` +
`format=json&` +
`addressdetails=1&` +
`countrycodes=us&` +
`limit=5`,
{
headers: {
'User-Agent': 'CommunityOne-Navigator/1.0'
}
}
)
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
}, [])
setSuggestions(uniqueResults)
setShowSuggestions(uniqueResults.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 {
// Use Nominatim (OpenStreetMap) geocoding service
const response = await fetch(
`https://nominatim.openstreetmap.org/search?` +
`q=${encodeURIComponent(addressToLookup)}&` +
`format=json&` +
`addressdetails=1&` +
`countrycodes=us&` +
`limit=5`,
{
headers: {
'User-Agent': 'CommunityOne-Navigator/1.0'
}
}
)
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
const 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
}, [])
// 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
// Convert state name to 2-letter code
const stateName = addr.state || ''
const stateCode = stateNameToCode(stateName)
console.log(`๐Ÿ—บ๏ธ [AddressLookup] State conversion: "${stateName}" โ†’ "${stateCode}"`)
const locationData: LocationData = {
address: result.display_name,
state: stateCode,
county: addr.county || '',
city: addr.city || addr.town || addr.village || addr.municipality || '',
latitude: parseFloat(result.lat),
longitude: parseFloat(result.lon),
}
// Validate we got the essential data
if (!locationData.state || !locationData.city) {
setError('Could not determine city and state from this address. Please be more specific.')
setSuggestions([])
setShowSuggestions(false)
return
}
console.log('๐Ÿ“ [AddressLookup] Location found:', locationData)
setSuggestions([])
setShowSuggestions(false)
setFoundLocation(locationData)
onLocationFound(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) => {
setAddress(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 using Nominatim
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?` +
`lat=${latitude}&` +
`lon=${longitude}&` +
`format=json&` +
`addressdetails=1`,
{
headers: {
'User-Agent': 'CommunityOne-Navigator/1.0'
}
}
)
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: 5000, // Reduced timeout since network location is faster
maximumAge: 30000 // Allow 30s cached location for faster response
}
)
}
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>
)}
</form>
)
}
return (
<div className="w-full">
<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" />
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="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">
We'll find your local organizations based on your address
</p>
{/* Use My Location Button */}
<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>Use My Current Location</span>
</button>
</div>
{/* Divider */}
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="px-2 bg-white text-gray-500">or enter manually</span>
</div>
</div>
</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>
{/* 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>
)}
{/* 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={() => {
window.location.href = `/documents?state=${foundLocation.state}&city=${foundLocation.city}`
}}
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={() => {
window.location.href = `/nonprofits?state=${foundLocation.state}&city=${foundLocation.city}`
}}
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('')
setError(null)
clearLocation() // Clear the global location context
}}
className="text-sm text-primary-600 hover:text-primary-700 font-medium underline"
>
Search Different Address
</button>
</div>
</div>
</div>
)}
</div>
)
}