import React, { useState, useEffect, useRef, useCallback } from 'react'; import { placesService, AutocompletePrediction, PlaceDetails } from '../services/placesService'; export interface AddressAutocompleteProps { value: string; onChange: (value: string) => void; onPlaceSelect?: (place: PlaceDetails) => void; onError?: (error: string) => void; placeholder?: string; className?: string; id?: string; name?: string; required?: boolean; countryRestriction?: string; types?: string[]; disabled?: boolean; } const AddressAutocomplete: React.FC = ({ value, onChange, onPlaceSelect, onError, placeholder = "Enter address", className = "form-control", id, name, required = false, countryRestriction, types = ['address'], disabled = false }) => { const [predictions, setPredictions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedIndex, setSelectedIndex] = useState(-1); const [isSelectingPlace, setIsSelectingPlace] = useState(false); const wrapperRef = useRef(null); const inputRef = useRef(null); const dropdownRef = useRef(null); const debounceTimer = useRef(undefined); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { setShowDropdown(false); setSelectedIndex(-1); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, []); // Cleanup timer on unmount useEffect(() => { return () => { if (debounceTimer.current) { clearTimeout(debounceTimer.current); } }; }, []); // Fetch autocomplete predictions const fetchPredictions = useCallback(async (query: string) => { if (query.trim().length < 2 || isSelectingPlace) { setPredictions([]); return; } setLoading(true); setError(null); try { const options = { types, componentRestrictions: countryRestriction ? { country: countryRestriction } : undefined }; const results = await placesService.getAutocompletePredictions(query, options); setPredictions(results); } catch (err) { console.error('Error fetching place predictions:', err); const errorMessage = 'Failed to load suggestions'; setError(errorMessage); setPredictions([]); // Notify parent component of error if (onError) { onError(errorMessage); } } finally { setLoading(false); } }, [types, countryRestriction, isSelectingPlace, onError]); // Handle input change with debouncing const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; onChange(newValue); setShowDropdown(true); setSelectedIndex(-1); setIsSelectingPlace(false); // Clear previous timer if (debounceTimer.current) { clearTimeout(debounceTimer.current); } // Debounce API calls debounceTimer.current = window.setTimeout(() => { fetchPredictions(newValue); }, 300); }; // Handle place selection const handlePlaceSelect = async (prediction: AutocompletePrediction) => { setIsSelectingPlace(true); setLoading(true); setShowDropdown(false); setSelectedIndex(-1); try { const placeDetails = await placesService.getPlaceDetails(prediction.placeId); onChange(placeDetails.formattedAddress); if (onPlaceSelect) { onPlaceSelect(placeDetails); } } catch (err) { console.error('Error fetching place details:', err); const errorMessage = 'Failed to load place details'; setError(errorMessage); // Keep the description as fallback onChange(prediction.description); // Notify parent component of error if (onError) { onError(errorMessage); } } finally { setLoading(false); setIsSelectingPlace(false); } }; // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (!showDropdown || predictions.length === 0) { return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => prev < predictions.length - 1 ? prev + 1 : prev ); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); break; case 'Enter': e.preventDefault(); if (selectedIndex >= 0 && selectedIndex < predictions.length) { handlePlaceSelect(predictions[selectedIndex]); } break; case 'Escape': setShowDropdown(false); setSelectedIndex(-1); inputRef.current?.blur(); break; default: break; } }; // Handle input focus const handleFocus = () => { if (predictions.length > 0) { setShowDropdown(true); } }; return (
= 0 ? `${id}-option-${selectedIndex}` : undefined } /> {/* Loading spinner */} {loading && (
Loading...
)} {/* Dropdown with predictions */} {showDropdown && (predictions.length > 0 || error || loading) && (
{error && (
{error}
)} {loading && predictions.length === 0 && !error && (
Searching addresses...
)} {predictions.map((prediction, index) => (
handlePlaceSelect(prediction)} onMouseEnter={() => setSelectedIndex(index)} onMouseLeave={() => setSelectedIndex(-1)} role="option" aria-selected={index === selectedIndex} >
{prediction.mainText}
{prediction.secondaryText && (
{prediction.secondaryText}
)}
))} {predictions.length > 0 && (
Powered by Google
)}
)}
); }; export default AddressAutocomplete;