Google maps integration
This commit is contained in:
@@ -1,42 +1,53 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { placesService, AutocompletePrediction, PlaceDetails } from '../services/placesService';
|
||||
|
||||
interface AddressSuggestion {
|
||||
place_id: string;
|
||||
display_name: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
}
|
||||
|
||||
interface AddressAutocompleteProps {
|
||||
export interface AddressAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string, lat?: number, lon?: number) => void;
|
||||
onChange: (value: string) => void;
|
||||
onPlaceSelect?: (place: PlaceDetails) => void;
|
||||
onError?: (error: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
countryRestriction?: string;
|
||||
types?: string[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Address",
|
||||
required = false,
|
||||
onPlaceSelect,
|
||||
onError,
|
||||
placeholder = "Enter address",
|
||||
className = "form-control",
|
||||
id,
|
||||
name
|
||||
name,
|
||||
required = false,
|
||||
countryRestriction,
|
||||
types = ['address'],
|
||||
disabled = false
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [predictions, setPredictions] = useState<AutocompletePrediction[]>([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isSelectingPlace, setIsSelectingPlace] = useState(false);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const debounceTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
// Handle clicking outside to close suggestions
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
setShowDropdown(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,100 +66,249 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchAddressSuggestions = async (query: string) => {
|
||||
if (query.length < 3) {
|
||||
setSuggestions([]);
|
||||
// Fetch autocomplete predictions
|
||||
const fetchPredictions = useCallback(async (query: string) => {
|
||||
if (query.trim().length < 2 || isSelectingPlace) {
|
||||
setPredictions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Using Nominatim API (OpenStreetMap) for free geocoding
|
||||
// In production, you might want to use Google Places API or another service
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?` +
|
||||
`q=${encodeURIComponent(query)}&` +
|
||||
`format=json&` +
|
||||
`limit=5&` +
|
||||
`countrycodes=us`
|
||||
);
|
||||
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([]);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuggestions(data);
|
||||
// Notify parent component of error
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching address suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [types, countryRestriction, isSelectingPlace, onError]);
|
||||
|
||||
// Handle input change with debouncing
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
setShowSuggestions(true);
|
||||
setShowDropdown(true);
|
||||
setSelectedIndex(-1);
|
||||
setIsSelectingPlace(false);
|
||||
|
||||
// Debounce the API call
|
||||
// Clear previous timer
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// Debounce API calls
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
fetchAddressSuggestions(newValue);
|
||||
fetchPredictions(newValue);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: AddressSuggestion) => {
|
||||
onChange(
|
||||
suggestion.display_name,
|
||||
parseFloat(suggestion.lat),
|
||||
parseFloat(suggestion.lon)
|
||||
);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
// 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div ref={wrapperRef} className="position-relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={className}
|
||||
className={`${className} ${loading ? 'pe-5' : ''}`}
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
aria-expanded={showDropdown}
|
||||
aria-haspopup="listbox"
|
||||
aria-owns={showDropdown ? `${id}-dropdown` : undefined}
|
||||
aria-activedescendant={
|
||||
selectedIndex >= 0 ? `${id}-option-${selectedIndex}` : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{showSuggestions && (suggestions.length > 0 || loading) && (
|
||||
{/* Loading spinner */}
|
||||
{loading && (
|
||||
<div
|
||||
className="position-absolute w-100 bg-white border rounded-bottom shadow-sm"
|
||||
style={{ top: '100%', zIndex: 1000, maxHeight: '300px', overflowY: 'auto' }}
|
||||
className="position-absolute top-50 end-0 translate-middle-y me-3"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="p-2 text-center text-muted">
|
||||
<small>Searching addresses...</small>
|
||||
<div
|
||||
className="spinner-border spinner-border-sm text-muted"
|
||||
role="status"
|
||||
style={{ width: '1rem', height: '1rem' }}
|
||||
>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown with predictions */}
|
||||
{showDropdown && (predictions.length > 0 || error || loading) && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
id={`${id}-dropdown`}
|
||||
className="position-absolute w-100 bg-white border rounded-bottom shadow-sm"
|
||||
style={{
|
||||
top: '100%',
|
||||
zIndex: 1050,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderTop: 'none',
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{error && (
|
||||
<div className="p-3 text-center text-danger">
|
||||
<small>
|
||||
<i className="bi bi-exclamation-triangle me-1"></i>
|
||||
{error}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.place_id}
|
||||
className="p-2 border-bottom cursor-pointer"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
onMouseEnter={(e) => e.currentTarget.classList.add('bg-light')}
|
||||
onMouseLeave={(e) => e.currentTarget.classList.remove('bg-light')}
|
||||
>
|
||||
<small className="d-block text-truncate">
|
||||
{suggestion.display_name}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{loading && predictions.length === 0 && !error && (
|
||||
<div className="p-3 text-center text-muted">
|
||||
<small>
|
||||
<i className="bi bi-search me-1"></i>
|
||||
Searching addresses...
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{predictions.map((prediction, index) => (
|
||||
<div
|
||||
key={prediction.placeId}
|
||||
id={`${id}-option-${index}`}
|
||||
className={`p-3 border-bottom cursor-pointer ${
|
||||
index === selectedIndex ? 'bg-light' : ''
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handlePlaceSelect(prediction)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onMouseLeave={() => setSelectedIndex(-1)}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
<i className="bi bi-geo-alt text-muted me-2 mt-1" style={{ fontSize: '0.875rem' }}></i>
|
||||
<div className="flex-grow-1 min-width-0">
|
||||
<div className="text-truncate fw-medium">
|
||||
{prediction.mainText}
|
||||
</div>
|
||||
{prediction.secondaryText && (
|
||||
<div className="text-muted small text-truncate">
|
||||
{prediction.secondaryText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
</div>
|
||||
))}
|
||||
|
||||
{predictions.length > 0 && (
|
||||
<div className="p-2 text-center border-top bg-light">
|
||||
<small className="text-muted d-flex align-items-center justify-content-center">
|
||||
<img
|
||||
src="https://developers.google.com/maps/documentation/places/web-service/images/powered_by_google_on_white.png"
|
||||
alt="Powered by Google"
|
||||
style={{ height: '12px' }}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user