Google maps integration
This commit is contained in:
2140
frontend/package-lock.json
generated
2140
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@googlemaps/js-api-loader": "^1.16.10",
|
||||
"@stripe/react-stripe-js": "^3.3.1",
|
||||
"@stripe/stripe-js": "^5.2.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -19,8 +22,6 @@
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"stripe": "^18.4.0",
|
||||
"@stripe/react-stripe-js": "^3.3.1",
|
||||
"@stripe/stripe-js": "^5.2.0",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@@ -55,6 +56,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"dotenv-cli": "^9.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import api, { addressAPI, userAPI, itemAPI } from "../services/api";
|
||||
@@ -83,6 +83,9 @@ const CreateItem: React.FC = () => {
|
||||
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||
const [addressesLoading, setAddressesLoading] = useState(true);
|
||||
|
||||
// Reference to LocationForm geocoding function
|
||||
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserAddresses();
|
||||
@@ -150,6 +153,15 @@ const CreateItem: React.FC = () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
// Try to geocode the address before submitting
|
||||
if (geocodeLocationRef.current) {
|
||||
try {
|
||||
await geocodeLocationRef.current();
|
||||
} catch (error) {
|
||||
console.warn('Geocoding failed, creating item without coordinates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// For now, we'll store image URLs as base64 strings
|
||||
// In production, you'd upload to a service like S3
|
||||
@@ -253,6 +265,14 @@ const CreateItem: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoordinatesChange = (latitude: number, longitude: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
latitude,
|
||||
longitude,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddressSelect = (addressId: string) => {
|
||||
if (addressId === "new") {
|
||||
// Clear form for new address entry
|
||||
@@ -379,6 +399,10 @@ const CreateItem: React.FC = () => {
|
||||
onChange={handleChange}
|
||||
onAddressSelect={handleAddressSelect}
|
||||
formatAddressDisplay={formatAddressDisplay}
|
||||
onCoordinatesChange={handleCoordinatesChange}
|
||||
onGeocodeRef={(geocodeFunction) => {
|
||||
geocodeLocationRef.current = geocodeFunction;
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeliveryOptions
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { itemRequestAPI } from "../services/api";
|
||||
import AddressAutocomplete from "../components/AddressAutocomplete";
|
||||
|
||||
const CreateItemRequest: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -42,18 +41,6 @@ const CreateItemRequest: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressChange = (value: string, lat?: number, lon?: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
address1: value,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
city: prev.city,
|
||||
state: prev.state,
|
||||
zipCode: prev.zipCode,
|
||||
country: prev.country,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -186,10 +173,14 @@ const CreateItemRequest: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Address</label>
|
||||
<AddressAutocomplete
|
||||
<label htmlFor="address1" className="form-label">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={formData.address1}
|
||||
onChange={handleAddressChange}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter your address or area"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Item, Rental, Address } from "../types";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
@@ -58,6 +58,9 @@ const EditItem: React.FC = () => {
|
||||
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||
const [addressesLoading, setAddressesLoading] = useState(true);
|
||||
|
||||
// Reference to LocationForm geocoding function
|
||||
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const [formData, setFormData] = useState<ItemFormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -204,10 +207,27 @@ const EditItem: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoordinatesChange = (latitude: number, longitude: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
latitude,
|
||||
longitude,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Try to geocode the address before submitting
|
||||
if (geocodeLocationRef.current) {
|
||||
try {
|
||||
await geocodeLocationRef.current();
|
||||
} catch (error) {
|
||||
console.warn('Geocoding failed, updating item without coordinates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Use existing image previews (which includes both old and new images)
|
||||
const imageUrls = imagePreviews;
|
||||
@@ -412,6 +432,10 @@ const EditItem: React.FC = () => {
|
||||
onChange={handleChange}
|
||||
onAddressSelect={handleAddressSelect}
|
||||
formatAddressDisplay={formatAddressDisplay}
|
||||
onCoordinatesChange={handleCoordinatesChange}
|
||||
onGeocodeRef={(geocodeFunction) => {
|
||||
geocodeLocationRef.current = geocodeFunction;
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeliveryOptions
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Item, Rental } from "../types";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { itemAPI, rentalAPI } from "../services/api";
|
||||
import LocationMap from "../components/LocationMap";
|
||||
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
||||
import ItemReviews from "../components/ItemReviews";
|
||||
|
||||
const ItemDetail: React.FC = () => {
|
||||
@@ -357,11 +357,9 @@ const ItemDetail: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<LocationMap
|
||||
<GoogleMapWithRadius
|
||||
latitude={item.latitude}
|
||||
longitude={item.longitude}
|
||||
location={item.location}
|
||||
itemName={item.name}
|
||||
/>
|
||||
|
||||
<ItemReviews itemId={item.id} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { userAPI, itemAPI, rentalAPI, addressAPI } from "../services/api";
|
||||
import { User, Item, Rental, Address } from "../types";
|
||||
@@ -6,8 +6,11 @@ import { getImageUrl } from "../utils/imageUrl";
|
||||
import AvailabilitySettings from "../components/AvailabilitySettings";
|
||||
import ReviewItemModal from "../components/ReviewModal";
|
||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||
import StarRating from "../components/StarRating";
|
||||
import ReviewDetailsModal from "../components/ReviewDetailsModal";
|
||||
import {
|
||||
geocodingService,
|
||||
AddressComponents,
|
||||
} from "../services/geocodingService";
|
||||
|
||||
const Profile: React.FC = () => {
|
||||
const { user, updateUser, logout } = useAuth();
|
||||
@@ -62,7 +65,14 @@ const Profile: React.FC = () => {
|
||||
state: "",
|
||||
zipCode: "",
|
||||
country: "US",
|
||||
latitude: undefined as number | undefined,
|
||||
longitude: undefined as number | undefined,
|
||||
});
|
||||
const [addressGeocoding, setAddressGeocoding] = useState(false);
|
||||
const [addressGeocodeError, setAddressGeocodeError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [addressGeocodeSuccess, setAddressGeocodeSuccess] = useState(false);
|
||||
|
||||
// Rental history state
|
||||
const [pastRenterRentals, setPastRenterRentals] = useState<Rental[]>([]);
|
||||
@@ -404,6 +414,8 @@ const Profile: React.FC = () => {
|
||||
state: "",
|
||||
zipCode: "",
|
||||
country: "US",
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
});
|
||||
setEditingAddressId(null);
|
||||
setShowAddressForm(true);
|
||||
@@ -417,6 +429,8 @@ const Profile: React.FC = () => {
|
||||
state: address.state,
|
||||
zipCode: address.zipCode,
|
||||
country: address.country,
|
||||
latitude: address.latitude,
|
||||
longitude: address.longitude,
|
||||
});
|
||||
setEditingAddressId(address.id);
|
||||
setShowAddressForm(true);
|
||||
@@ -429,8 +443,59 @@ const Profile: React.FC = () => {
|
||||
setAddressFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Geocoding function for address form
|
||||
const geocodeAddressForm = useCallback(
|
||||
async (addressData: typeof addressFormData) => {
|
||||
if (
|
||||
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAddressGeocoding(true);
|
||||
setAddressGeocodeError(null);
|
||||
setAddressGeocodeSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await geocodingService.geocodeAddress(
|
||||
addressData as AddressComponents
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
setAddressGeocodeError(result.details || result.error);
|
||||
} else {
|
||||
setAddressGeocodeSuccess(true);
|
||||
setAddressFormData((prev) => ({
|
||||
...prev,
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
}));
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setAddressGeocodeSuccess(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
setAddressGeocodeError("Failed to geocode address");
|
||||
} finally {
|
||||
setAddressGeocoding(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSaveAddress = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Try to geocode the address before saving
|
||||
try {
|
||||
await geocodeAddressForm(addressFormData);
|
||||
} catch (error) {
|
||||
// Geocoding failed, but we'll continue with saving
|
||||
console.warn(
|
||||
"Geocoding failed, saving address without coordinates:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingAddressId) {
|
||||
// Update existing address
|
||||
@@ -469,7 +534,12 @@ const Profile: React.FC = () => {
|
||||
state: "",
|
||||
zipCode: "",
|
||||
country: "US",
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
});
|
||||
setAddressGeocoding(false);
|
||||
setAddressGeocodeError(null);
|
||||
setAddressGeocodeSuccess(false);
|
||||
};
|
||||
|
||||
const usStates = [
|
||||
|
||||
@@ -129,7 +129,7 @@ const PublicProfile: React.FC = () => {
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h6 className="card-title">{item.name}</h6>
|
||||
<p className="card-text text-muted small">{item.location}</p>
|
||||
<p className="card-text text-muted small">{item.city && item.state ? `${item.city}, ${item.state}` : ''}</p>
|
||||
<div>
|
||||
{item.pricePerDay && (
|
||||
<span className="badge bg-primary">${item.pricePerDay}/day</span>
|
||||
|
||||
@@ -280,7 +280,7 @@ const RentItem: React.FC = () => {
|
||||
<p className="text-muted small">
|
||||
{item.city && item.state
|
||||
? `${item.city}, ${item.state}`
|
||||
: item.location}
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
@@ -119,9 +119,24 @@ export const stripeAPI = {
|
||||
createAccountLink: (data: { refreshUrl: string; returnUrl: string }) =>
|
||||
api.post("/stripe/account-links", data),
|
||||
getAccountStatus: () => api.get("/stripe/account-status"),
|
||||
createSetupCheckoutSession: (data: {
|
||||
rentalData?: any;
|
||||
}) => api.post("/stripe/create-setup-checkout-session", data),
|
||||
createSetupCheckoutSession: (data: { rentalData?: any }) =>
|
||||
api.post("/stripe/create-setup-checkout-session", data),
|
||||
};
|
||||
|
||||
export const mapsAPI = {
|
||||
placesAutocomplete: (data: {
|
||||
input: string;
|
||||
types?: string[];
|
||||
componentRestrictions?: { country: string };
|
||||
sessionToken?: string;
|
||||
}) => api.post("/maps/places/autocomplete", data),
|
||||
placeDetails: (data: { placeId: string; sessionToken?: string }) =>
|
||||
api.post("/maps/places/details", data),
|
||||
geocode: (data: {
|
||||
address: string;
|
||||
componentRestrictions?: { country: string };
|
||||
}) => api.post("/maps/geocode", data),
|
||||
getHealth: () => api.get("/maps/health"),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
118
frontend/src/services/geocodingService.ts
Normal file
118
frontend/src/services/geocodingService.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { mapsAPI } from "./api";
|
||||
|
||||
interface AddressComponents {
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface GeocodeResult {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
formattedAddress?: string;
|
||||
}
|
||||
|
||||
interface GeocodeError {
|
||||
error: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
type GeocodeResponse = GeocodeResult | GeocodeError;
|
||||
|
||||
class GeocodingService {
|
||||
private cache: Map<string, GeocodeResult> = new Map();
|
||||
|
||||
/**
|
||||
* Convert address components to lat/lng coordinates using backend geocoding proxy
|
||||
*/
|
||||
async geocodeAddress(address: AddressComponents): Promise<GeocodeResponse> {
|
||||
// Create address string for geocoding
|
||||
const addressParts = [
|
||||
address.address1,
|
||||
address.address2,
|
||||
address.city,
|
||||
address.state,
|
||||
address.zipCode,
|
||||
address.country,
|
||||
].filter((part) => part && part.trim());
|
||||
|
||||
const addressString = addressParts.join(", ");
|
||||
const cacheKey = addressString.toLowerCase();
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mapsAPI.geocode({
|
||||
address: addressString,
|
||||
componentRestrictions: {
|
||||
country: address.country?.toLowerCase() || "us",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.latitude && response.data.longitude) {
|
||||
const geocodeResult: GeocodeResult = {
|
||||
latitude: response.data.latitude,
|
||||
longitude: response.data.longitude,
|
||||
formattedAddress: response.data.formattedAddress || addressString,
|
||||
};
|
||||
|
||||
// Cache successful result
|
||||
this.cache.set(cacheKey, geocodeResult);
|
||||
return geocodeResult;
|
||||
} else if (response.data.error) {
|
||||
return {
|
||||
error: "Geocoding failed",
|
||||
details: response.data.error,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: "Geocoding failed",
|
||||
details: "No coordinates returned",
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Geocoding API error:", error.message);
|
||||
|
||||
if (error.response?.status === 429) {
|
||||
return {
|
||||
error: "Too many geocoding requests",
|
||||
details: "Please slow down and try again",
|
||||
};
|
||||
} else if (error.response?.status === 401) {
|
||||
return {
|
||||
error: "Authentication required",
|
||||
details: "Please log in to use geocoding",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: "Network error during geocoding",
|
||||
details: error.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if address has sufficient components for geocoding
|
||||
*/
|
||||
isAddressComplete(address: AddressComponents): boolean {
|
||||
return !!(
|
||||
address.address1?.trim() &&
|
||||
address.city?.trim() &&
|
||||
address.state?.trim() &&
|
||||
address.zipCode?.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const geocodingService = new GeocodingService();
|
||||
|
||||
// Export types for use in other components
|
||||
export type { AddressComponents, GeocodeResult, GeocodeError, GeocodeResponse };
|
||||
135
frontend/src/services/placesService.ts
Normal file
135
frontend/src/services/placesService.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { mapsAPI } from "./api";
|
||||
|
||||
// Define types for place details
|
||||
export interface PlaceDetails {
|
||||
formattedAddress: string;
|
||||
addressComponents: {
|
||||
streetNumber?: string;
|
||||
route?: string;
|
||||
locality?: string;
|
||||
administrativeAreaLevel1?: string;
|
||||
administrativeAreaLevel1Long?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
};
|
||||
geometry: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
placeId: string;
|
||||
}
|
||||
|
||||
export interface AutocompletePrediction {
|
||||
placeId: string;
|
||||
description: string;
|
||||
types: string[];
|
||||
mainText: string;
|
||||
secondaryText: string;
|
||||
}
|
||||
|
||||
class PlacesService {
|
||||
private sessionToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Generate a new session token for cost optimization
|
||||
*/
|
||||
private generateSessionToken(): string {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete predictions for a query
|
||||
*/
|
||||
async getAutocompletePredictions(
|
||||
input: string,
|
||||
options?: {
|
||||
types?: string[];
|
||||
componentRestrictions?: { country: string };
|
||||
bounds?: any;
|
||||
}
|
||||
): Promise<AutocompletePrediction[]> {
|
||||
if (input.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Generate new session token if not exists
|
||||
if (!this.sessionToken) {
|
||||
this.sessionToken = this.generateSessionToken();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mapsAPI.placesAutocomplete({
|
||||
input: input.trim(),
|
||||
types: options?.types || ["address"],
|
||||
componentRestrictions: options?.componentRestrictions,
|
||||
sessionToken: this.sessionToken || undefined,
|
||||
});
|
||||
|
||||
if (response.data.predictions) {
|
||||
return response.data.predictions;
|
||||
} else if (response.data.error) {
|
||||
console.error("Places Autocomplete API error:", response.data.error);
|
||||
throw new Error(response.data.error);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching place predictions:", error.message);
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Too many requests. Please slow down.");
|
||||
} else if (error.response?.status === 401) {
|
||||
throw new Error("Authentication required. Please log in.");
|
||||
} else {
|
||||
throw new Error("Failed to fetch place suggestions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed place information by place ID
|
||||
*/
|
||||
async getPlaceDetails(placeId: string): Promise<PlaceDetails> {
|
||||
if (!placeId) {
|
||||
throw new Error("Place ID is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mapsAPI.placeDetails({
|
||||
placeId,
|
||||
sessionToken: this.sessionToken || undefined,
|
||||
});
|
||||
|
||||
// Clear session token after successful place details request
|
||||
this.sessionToken = null;
|
||||
|
||||
if (response.data.placeId) {
|
||||
return {
|
||||
formattedAddress: response.data.formattedAddress,
|
||||
addressComponents: response.data.addressComponents,
|
||||
geometry: response.data.geometry,
|
||||
placeId: response.data.placeId,
|
||||
};
|
||||
} else if (response.data.error) {
|
||||
console.error("Place Details API error:", response.data.error);
|
||||
throw new Error(response.data.error);
|
||||
}
|
||||
|
||||
throw new Error("Invalid response from place details API");
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching place details:", error.message);
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Too many requests. Please slow down.");
|
||||
} else if (error.response?.status === 401) {
|
||||
throw new Error("Authentication required. Please log in.");
|
||||
} else {
|
||||
throw new Error("Failed to fetch place details");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const placesService = new PlacesService();
|
||||
@@ -62,7 +62,6 @@ export interface Item {
|
||||
pricePerWeek?: number;
|
||||
pricePerMonth?: number;
|
||||
replacementCost: number;
|
||||
location: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
city?: string;
|
||||
|
||||
Reference in New Issue
Block a user