import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react'; import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService'; export const toPlace = (result) => standardizePlace(result); export const geocodeAddress = async (address, options = {}) => { return await serviceGeocodeAddress(address, options); }; const useDebouncedCallback = (callback, delay) => { const timeoutRef = useRef(null); return React.useCallback( (...args) => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => { callback(...args); }, delay); }, [callback, delay] ); }; const AddressAutocomplete = ({ id, label, placeholder = 'Search address or locality…', value, onChange, onPlaceSelected, onOpenMapPin, bias, fullWidth = true, disabled = false, className = '', inputClassName = '', required = false }) => { const [inputValue, setInputValue] = useState(value || ''); const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); const [activeOption, setActiveOption] = useState(-1); const wrapRef = useRef(null); const activeRef = useRef(0); const skipNextSearchRef = useRef(false); const listId = `${id || 'address'}-suggestions`; const optionId = (index) => `${listId}-option-${index}`; useEffect(() => { if (value !== undefined && value !== inputValue) { setInputValue(value || ''); } }, [value]); const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`; const fetchPredictions = useDebouncedCallback((query) => { const seq = ++activeRef.current; getAddressSuggestions(query, { bias, limit: 6 }) .then((results) => { if (seq !== activeRef.current) return; setLoading(false); setOptions(results || []); setIsOpen((results || []).length > 0); }) .catch(() => { if (seq !== activeRef.current) return; setLoading(false); setOptions([]); }); }, 220); useEffect(() => { if (skipNextSearchRef.current) { skipNextSearchRef.current = false; return; } if (!inputValue || inputValue.length < 2) { activeRef.current++; setOptions([]); setLoading(false); setIsOpen(false); return; } setLoading(true); fetchPredictions(inputValue); }, [inputValue, biasKey]); useEffect(() => { setActiveOption(-1); }, [options]); useEffect(() => { const onDocDown = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) { setIsOpen(false); } }; document.addEventListener('mousedown', onDocDown); return () => document.removeEventListener('mousedown', onDocDown); }, []); const pick = (option) => { const formatted = option.formatted_address || option.name || ''; skipNextSearchRef.current = true; activeRef.current++; setInputValue(formatted); onChange?.(formatted); onPlaceSelected?.(option); setIsOpen(false); setActiveOption(-1); }; const onKeyDown = (e) => { const listOpen = isOpen && options.length > 0; if (e.key === 'Escape') { setIsOpen(false); setActiveOption(-1); return; } if (!listOpen) return; if (e.key === 'ArrowDown') { e.preventDefault(); setActiveOption((current) => (current + 1) % options.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1)); } else if (e.key === 'Enter' && activeOption >= 0) { e.preventDefault(); pick(options[activeOption]); } }; return (
{label && ( )} {onOpenMapPin && ( )}
0} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined} onChange={(e) => { setInputValue(e.target.value); onChange?.(e.target.value); }} onKeyDown={(e) => { onKeyDown(e); if (e.key === 'Escape') setIsOpen(false); }} className={`w-full h-9 pl-3 pr-16 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`} /> {/* Action icons on the right */}
{loading ? ( ) : inputValue && !disabled ? ( ) : null} {onOpenMapPin && !disabled && ( )}
{isOpen && options.length > 0 && ( )}
); }; AddressAutocomplete.propTypes = { id: PropTypes.string, label: PropTypes.string, placeholder: PropTypes.string, value: PropTypes.string, onChange: PropTypes.func, onPlaceSelected: PropTypes.func, onOpenMapPin: PropTypes.func, bias: PropTypes.object, fullWidth: PropTypes.bool, disabled: PropTypes.bool, className: PropTypes.string, inputClassName: PropTypes.string, required: PropTypes.bool }; export default AddressAutocomplete;