Google maps integration

This commit is contained in:
jackiettran
2025-09-09 22:49:55 -04:00
parent 69bf64fe70
commit 1d7db138df
25 changed files with 3711 additions and 577 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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>
)}

View 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;

View File

@@ -29,7 +29,7 @@ const ItemCard: React.FC<ItemCardProps> = ({
const getLocationDisplay = () => {
return item.city && item.state
? `${item.city}, ${item.state}`
: item.location;
: '';
};
return (

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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} />

View File

@@ -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 = [

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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;

View 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 };

View 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();

View File

@@ -62,7 +62,6 @@ export interface Item {
pricePerWeek?: number;
pricePerMonth?: number;
replacementCost: number;
location: string;
address1?: string;
address2?: string;
city?: string;