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>
|
||||
)}
|
||||
|
||||
151
frontend/src/components/GoogleMapWithRadius.tsx
Normal file
151
frontend/src/components/GoogleMapWithRadius.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useMemo, useEffect, useRef } from "react";
|
||||
import { Loader } from "@googlemaps/js-api-loader";
|
||||
|
||||
interface GoogleMapWithRadiusProps {
|
||||
latitude?: number | string;
|
||||
longitude?: number | string;
|
||||
mapOptions?: {
|
||||
zoom?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Utility function to safely convert coordinates to numbers
|
||||
const safeParseNumber = (value: number | string | undefined): number | null => {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
return !isNaN(num) && isFinite(num) ? num : null;
|
||||
};
|
||||
|
||||
// 2 miles in meters for radius circle
|
||||
const RADIUS_METERS = 2 * 1609.34;
|
||||
|
||||
const GoogleMapWithRadius: React.FC<GoogleMapWithRadiusProps> = ({
|
||||
latitude: rawLatitude,
|
||||
longitude: rawLongitude,
|
||||
mapOptions = {},
|
||||
}) => {
|
||||
// Convert coordinates to numbers safely
|
||||
const latitude = safeParseNumber(rawLatitude);
|
||||
const longitude = safeParseNumber(rawLongitude);
|
||||
|
||||
// Destructure mapOptions to create stable references
|
||||
const { zoom = 12 } = mapOptions;
|
||||
|
||||
// Get API key from environment
|
||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY;
|
||||
|
||||
// Refs for map container and instances
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
||||
const circleRef = useRef<google.maps.Circle | null>(null);
|
||||
|
||||
// Memoize map center
|
||||
const mapCenter = useMemo(() => {
|
||||
if (latitude === null || longitude === null) return null;
|
||||
return { lat: latitude, lng: longitude };
|
||||
}, [latitude, longitude]);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!apiKey || !mapRef.current || !mapCenter) return;
|
||||
|
||||
const initializeMap = async () => {
|
||||
const loader = new Loader({
|
||||
apiKey,
|
||||
version: "weekly",
|
||||
});
|
||||
|
||||
try {
|
||||
await loader.importLibrary("maps");
|
||||
|
||||
if (!mapRef.current) return;
|
||||
|
||||
// Create map
|
||||
const map = new google.maps.Map(mapRef.current, {
|
||||
center: mapCenter,
|
||||
zoom: zoom,
|
||||
zoomControl: true,
|
||||
mapTypeControl: false,
|
||||
scaleControl: true,
|
||||
streetViewControl: false,
|
||||
rotateControl: false,
|
||||
fullscreenControl: false,
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Create circle overlay
|
||||
const circle = new google.maps.Circle({
|
||||
center: mapCenter,
|
||||
radius: RADIUS_METERS,
|
||||
fillColor: "#6c757d",
|
||||
fillOpacity: 0.2,
|
||||
strokeColor: "#6c757d",
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 2,
|
||||
map: map,
|
||||
});
|
||||
|
||||
circleRef.current = circle;
|
||||
} catch (error) {
|
||||
console.error("Failed to load Google Maps:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeMap();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (circleRef.current) {
|
||||
circleRef.current.setMap(null);
|
||||
}
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, [apiKey, mapCenter, zoom]);
|
||||
|
||||
// Update map center and circle when coordinates change
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !circleRef.current || !mapCenter) return;
|
||||
|
||||
mapInstanceRef.current.setCenter(mapCenter);
|
||||
circleRef.current.setCenter(mapCenter);
|
||||
}, [mapCenter]);
|
||||
|
||||
// Handle case where no coordinates are available
|
||||
if (latitude === null || longitude === null || mapCenter === null) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h5>Location</h5>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
height: "300px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-muted small">Map unavailable</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h5>Location</h5>
|
||||
<div
|
||||
ref={mapRef}
|
||||
style={{
|
||||
height: "300px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleMapWithRadius;
|
||||
@@ -29,7 +29,7 @@ const ItemCard: React.FC<ItemCardProps> = ({
|
||||
const getLocationDisplay = () => {
|
||||
return item.city && item.state
|
||||
? `${item.city}, ${item.state}`
|
||||
: item.location;
|
||||
: '';
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Address } from '../types';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Address } from "../types";
|
||||
import {
|
||||
geocodingService,
|
||||
AddressComponents,
|
||||
} from "../services/geocodingService";
|
||||
import AddressAutocomplete from "./AddressAutocomplete";
|
||||
import { PlaceDetails } from "../services/placesService";
|
||||
|
||||
interface LocationFormData {
|
||||
address1: string;
|
||||
@@ -17,11 +23,129 @@ interface LocationFormProps {
|
||||
userAddresses: Address[];
|
||||
selectedAddressId: string;
|
||||
addressesLoading: boolean;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
|
||||
onChange: (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => void;
|
||||
onAddressSelect: (addressId: string) => void;
|
||||
formatAddressDisplay: (address: Address) => string;
|
||||
onCoordinatesChange?: (latitude: number, longitude: number) => void;
|
||||
onGeocodeRef?: (geocodeFunction: () => Promise<boolean>) => void;
|
||||
}
|
||||
|
||||
// State constants - moved to top to avoid hoisting issues
|
||||
const usStates = [
|
||||
"Alabama",
|
||||
"Alaska",
|
||||
"Arizona",
|
||||
"Arkansas",
|
||||
"California",
|
||||
"Colorado",
|
||||
"Connecticut",
|
||||
"Delaware",
|
||||
"Florida",
|
||||
"Georgia",
|
||||
"Hawaii",
|
||||
"Idaho",
|
||||
"Illinois",
|
||||
"Indiana",
|
||||
"Iowa",
|
||||
"Kansas",
|
||||
"Kentucky",
|
||||
"Louisiana",
|
||||
"Maine",
|
||||
"Maryland",
|
||||
"Massachusetts",
|
||||
"Michigan",
|
||||
"Minnesota",
|
||||
"Mississippi",
|
||||
"Missouri",
|
||||
"Montana",
|
||||
"Nebraska",
|
||||
"Nevada",
|
||||
"New Hampshire",
|
||||
"New Jersey",
|
||||
"New Mexico",
|
||||
"New York",
|
||||
"North Carolina",
|
||||
"North Dakota",
|
||||
"Ohio",
|
||||
"Oklahoma",
|
||||
"Oregon",
|
||||
"Pennsylvania",
|
||||
"Rhode Island",
|
||||
"South Carolina",
|
||||
"South Dakota",
|
||||
"Tennessee",
|
||||
"Texas",
|
||||
"Utah",
|
||||
"Vermont",
|
||||
"Virginia",
|
||||
"Washington",
|
||||
"West Virginia",
|
||||
"Wisconsin",
|
||||
"Wyoming",
|
||||
];
|
||||
|
||||
// State code to full name mapping for Google Places API integration
|
||||
const stateCodeToName: { [key: string]: string } = {
|
||||
AL: "Alabama",
|
||||
AK: "Alaska",
|
||||
AZ: "Arizona",
|
||||
AR: "Arkansas",
|
||||
CA: "California",
|
||||
CO: "Colorado",
|
||||
CT: "Connecticut",
|
||||
DE: "Delaware",
|
||||
FL: "Florida",
|
||||
GA: "Georgia",
|
||||
HI: "Hawaii",
|
||||
ID: "Idaho",
|
||||
IL: "Illinois",
|
||||
IN: "Indiana",
|
||||
IA: "Iowa",
|
||||
KS: "Kansas",
|
||||
KY: "Kentucky",
|
||||
LA: "Louisiana",
|
||||
ME: "Maine",
|
||||
MD: "Maryland",
|
||||
MA: "Massachusetts",
|
||||
MI: "Michigan",
|
||||
MN: "Minnesota",
|
||||
MS: "Mississippi",
|
||||
MO: "Missouri",
|
||||
MT: "Montana",
|
||||
NE: "Nebraska",
|
||||
NV: "Nevada",
|
||||
NH: "New Hampshire",
|
||||
NJ: "New Jersey",
|
||||
NM: "New Mexico",
|
||||
NY: "New York",
|
||||
NC: "North Carolina",
|
||||
ND: "North Dakota",
|
||||
OH: "Ohio",
|
||||
OK: "Oklahoma",
|
||||
OR: "Oregon",
|
||||
PA: "Pennsylvania",
|
||||
RI: "Rhode Island",
|
||||
SC: "South Carolina",
|
||||
SD: "South Dakota",
|
||||
TN: "Tennessee",
|
||||
TX: "Texas",
|
||||
UT: "Utah",
|
||||
VT: "Vermont",
|
||||
VA: "Virginia",
|
||||
WA: "Washington",
|
||||
WV: "West Virginia",
|
||||
WI: "Wisconsin",
|
||||
WY: "Wyoming",
|
||||
DC: "District of Columbia",
|
||||
PR: "Puerto Rico",
|
||||
VI: "Virgin Islands",
|
||||
AS: "American Samoa",
|
||||
GU: "Guam",
|
||||
MP: "Northern Mariana Islands",
|
||||
};
|
||||
|
||||
const LocationForm: React.FC<LocationFormProps> = ({
|
||||
data,
|
||||
userAddresses,
|
||||
@@ -29,18 +153,155 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
addressesLoading,
|
||||
onChange,
|
||||
onAddressSelect,
|
||||
formatAddressDisplay
|
||||
formatAddressDisplay,
|
||||
onCoordinatesChange,
|
||||
onGeocodeRef,
|
||||
}) => {
|
||||
const usStates = [
|
||||
"Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut",
|
||||
"Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa",
|
||||
"Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan",
|
||||
"Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire",
|
||||
"New Jersey", "New Mexico", "New York", "North Carolina", "North Dakota", "Ohio",
|
||||
"Oklahoma", "Oregon", "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota",
|
||||
"Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington", "West Virginia",
|
||||
"Wisconsin", "Wyoming"
|
||||
];
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [geocodeError, setGeocodeError] = useState<string | null>(null);
|
||||
const [geocodeSuccess, setGeocodeSuccess] = useState(false);
|
||||
const [placesApiError, setPlacesApiError] = useState(false);
|
||||
|
||||
// Debounced geocoding function
|
||||
const geocodeAddress = useCallback(
|
||||
async (addressData: LocationFormData) => {
|
||||
if (
|
||||
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGeocoding(true);
|
||||
setGeocodeError(null);
|
||||
setGeocodeSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await geocodingService.geocodeAddress(
|
||||
addressData as AddressComponents
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
setGeocodeError(result.details || result.error);
|
||||
} else {
|
||||
setGeocodeSuccess(true);
|
||||
if (onCoordinatesChange) {
|
||||
onCoordinatesChange(result.latitude, result.longitude);
|
||||
}
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setGeocodeSuccess(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
setGeocodeError("Failed to geocode address");
|
||||
} finally {
|
||||
setGeocoding(false);
|
||||
}
|
||||
},
|
||||
[onCoordinatesChange]
|
||||
);
|
||||
|
||||
// Expose geocoding function to parent components
|
||||
const triggerGeocoding = useCallback(async () => {
|
||||
if (data.address1 && data.city && data.state && data.zipCode) {
|
||||
await geocodeAddress(data);
|
||||
return true; // Successfully triggered
|
||||
}
|
||||
return false; // Incomplete address
|
||||
}, [data, geocodeAddress]);
|
||||
|
||||
// Pass geocoding function to parent component
|
||||
useEffect(() => {
|
||||
if (onGeocodeRef) {
|
||||
onGeocodeRef(triggerGeocoding);
|
||||
}
|
||||
}, [onGeocodeRef, triggerGeocoding]);
|
||||
|
||||
// Handle place selection from autocomplete
|
||||
const handlePlaceSelect = useCallback(
|
||||
(place: PlaceDetails) => {
|
||||
try {
|
||||
const addressComponents = place.addressComponents;
|
||||
|
||||
// Build address1 from street number and route
|
||||
const streetNumber = addressComponents.streetNumber || "";
|
||||
const route = addressComponents.route || "";
|
||||
const address1 = `${streetNumber} ${route}`.trim();
|
||||
|
||||
// Create synthetic events to update form data
|
||||
const createSyntheticEvent = (name: string, value: string) =>
|
||||
({
|
||||
target: {
|
||||
name,
|
||||
value,
|
||||
type: "text",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
|
||||
// Update all address fields
|
||||
onChange(
|
||||
createSyntheticEvent("address1", address1 || place.formattedAddress)
|
||||
);
|
||||
|
||||
if (addressComponents.locality) {
|
||||
onChange(createSyntheticEvent("city", addressComponents.locality));
|
||||
}
|
||||
|
||||
if (addressComponents.administrativeAreaLevel1) {
|
||||
// Convert state code to full name using mapping, with fallback to long name or original code
|
||||
const stateCode = addressComponents.administrativeAreaLevel1;
|
||||
const stateName =
|
||||
stateCodeToName[stateCode] ||
|
||||
addressComponents.administrativeAreaLevel1Long ||
|
||||
stateCode;
|
||||
|
||||
// Only set the state if it exists in our dropdown options
|
||||
if (usStates.includes(stateName)) {
|
||||
onChange(createSyntheticEvent("state", stateName));
|
||||
} else {
|
||||
console.warn(
|
||||
`State not found in dropdown options: ${stateName} (code: ${stateCode})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (addressComponents.postalCode) {
|
||||
onChange(
|
||||
createSyntheticEvent("zipCode", addressComponents.postalCode)
|
||||
);
|
||||
}
|
||||
|
||||
if (addressComponents.country) {
|
||||
onChange(createSyntheticEvent("country", addressComponents.country));
|
||||
}
|
||||
|
||||
// Set coordinates immediately
|
||||
if (
|
||||
onCoordinatesChange &&
|
||||
place.geometry.latitude &&
|
||||
place.geometry.longitude
|
||||
) {
|
||||
onCoordinatesChange(
|
||||
place.geometry.latitude,
|
||||
place.geometry.longitude
|
||||
);
|
||||
}
|
||||
|
||||
// Clear any previous geocoding messages
|
||||
setGeocodeError(null);
|
||||
setGeocodeSuccess(true);
|
||||
setPlacesApiError(false);
|
||||
setTimeout(() => setGeocodeSuccess(false), 3000);
|
||||
} catch (error) {
|
||||
console.error("Error handling place selection:", error);
|
||||
setPlacesApiError(true);
|
||||
}
|
||||
},
|
||||
[onChange, onCoordinatesChange]
|
||||
);
|
||||
|
||||
// Handle Places API errors
|
||||
const handlePlacesApiError = useCallback(() => {
|
||||
setPlacesApiError(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card mb-4">
|
||||
@@ -48,8 +309,8 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
<div className="mb-3">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
Your address is private. This will only be used to show
|
||||
renters a general area.
|
||||
Your address is private. This will only be used to show renters a
|
||||
general area.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +332,7 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
onChange={(e) => onAddressSelect(e.target.value)}
|
||||
>
|
||||
<option value="new">Enter new address</option>
|
||||
{userAddresses.map(address => (
|
||||
{userAddresses.map((address) => (
|
||||
<option key={address.id} value={address.id}>
|
||||
{formatAddressDisplay(address)}
|
||||
</option>
|
||||
@@ -81,24 +342,59 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
)}
|
||||
|
||||
{/* Show form fields for all scenarios with addresses <= 1 or when "new" is selected */}
|
||||
{(userAddresses.length <= 1 ||
|
||||
{(userAddresses.length <= 1 ||
|
||||
(userAddresses.length > 1 && !selectedAddressId)) && (
|
||||
<>
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="address1" className="form-label">
|
||||
Address Line 1 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={data.address1}
|
||||
onChange={onChange}
|
||||
placeholder="123 Main Street"
|
||||
required
|
||||
/>
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<label htmlFor="address1" className="form-label mb-0">
|
||||
Address Line 1 *
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!placesApiError ? (
|
||||
<AddressAutocomplete
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={data.address1}
|
||||
onChange={(value) => {
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
name: "address1",
|
||||
value,
|
||||
type: "text",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(syntheticEvent);
|
||||
}}
|
||||
onPlaceSelect={handlePlaceSelect}
|
||||
onError={handlePlacesApiError}
|
||||
placeholder="Start typing an address..."
|
||||
className="form-control"
|
||||
required
|
||||
countryRestriction="us"
|
||||
types={["address"]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={data.address1}
|
||||
onChange={onChange}
|
||||
placeholder="123 Main Street"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{placesApiError && (
|
||||
<div className="text-muted small mt-1">
|
||||
<i className="bi bi-info-circle me-1"></i>
|
||||
Address autocomplete is unavailable. Using manual input.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="address2" className="form-label">
|
||||
@@ -144,7 +440,7 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
required
|
||||
>
|
||||
<option value="">Select State</option>
|
||||
{usStates.map(state => (
|
||||
{usStates.map((state) => (
|
||||
<option key={state} value={state}>
|
||||
{state}
|
||||
</option>
|
||||
@@ -176,4 +472,4 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationForm;
|
||||
export default LocationForm;
|
||||
|
||||
Reference in New Issue
Block a user