344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
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";
|
|
import {
|
|
useAddressAutocomplete,
|
|
usStates,
|
|
} from "../hooks/useAddressAutocomplete";
|
|
|
|
interface LocationFormData {
|
|
address1: string;
|
|
address2: string;
|
|
city: string;
|
|
state: string;
|
|
zipCode: string;
|
|
country: string;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
}
|
|
|
|
interface LocationFormProps {
|
|
data: LocationFormData;
|
|
userAddresses: Address[];
|
|
selectedAddressId: string;
|
|
addressesLoading: boolean;
|
|
onChange: (
|
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
|
) => void;
|
|
onAddressSelect: (addressId: string) => void;
|
|
formatAddressDisplay: (address: Address) => string;
|
|
onCoordinatesChange?: (latitude: number, longitude: number) => void;
|
|
onGeocodeRef?: (
|
|
geocodeFunction: () => Promise<{
|
|
latitude: number;
|
|
longitude: number;
|
|
} | null>
|
|
) => void;
|
|
}
|
|
|
|
const LocationForm: React.FC<LocationFormProps> = ({
|
|
data,
|
|
userAddresses,
|
|
selectedAddressId,
|
|
addressesLoading,
|
|
onChange,
|
|
onAddressSelect,
|
|
formatAddressDisplay,
|
|
onCoordinatesChange,
|
|
onGeocodeRef,
|
|
}) => {
|
|
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
|
|
): Promise<{ latitude: number; longitude: number } | null> => {
|
|
if (
|
|
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
setGeocoding(true);
|
|
setGeocodeError(null);
|
|
setGeocodeSuccess(false);
|
|
|
|
try {
|
|
const result = await geocodingService.geocodeAddress(
|
|
addressData as AddressComponents
|
|
);
|
|
|
|
if ("error" in result) {
|
|
setGeocodeError(result.details || result.error);
|
|
return null;
|
|
} else {
|
|
setGeocodeSuccess(true);
|
|
if (onCoordinatesChange) {
|
|
onCoordinatesChange(result.latitude, result.longitude);
|
|
}
|
|
// Clear success message after 3 seconds
|
|
setTimeout(() => setGeocodeSuccess(false), 3000);
|
|
|
|
// Return the coordinates
|
|
return { latitude: result.latitude, longitude: result.longitude };
|
|
}
|
|
} catch (error) {
|
|
setGeocodeError("Failed to geocode address");
|
|
return null;
|
|
} finally {
|
|
setGeocoding(false);
|
|
}
|
|
},
|
|
[onCoordinatesChange]
|
|
);
|
|
|
|
// Expose geocoding function to parent components
|
|
const triggerGeocoding = useCallback(async () => {
|
|
if (data.address1 && data.city && data.state && data.zipCode) {
|
|
const coordinates = await geocodeAddress(data);
|
|
return coordinates; // Return coordinates directly from geocoding
|
|
}
|
|
return null; // Incomplete address
|
|
}, [data, geocodeAddress]);
|
|
|
|
// Pass geocoding function to parent component
|
|
useEffect(() => {
|
|
if (onGeocodeRef) {
|
|
onGeocodeRef(triggerGeocoding);
|
|
}
|
|
}, [onGeocodeRef, triggerGeocoding]);
|
|
|
|
// Use address autocomplete hook
|
|
const { parsePlace } = useAddressAutocomplete();
|
|
|
|
// Handle place selection from autocomplete
|
|
const handlePlaceSelect = useCallback(
|
|
(place: PlaceDetails) => {
|
|
try {
|
|
const parsedAddress = parsePlace(place);
|
|
|
|
if (!parsedAddress) {
|
|
setPlacesApiError(true);
|
|
return;
|
|
}
|
|
|
|
// 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", parsedAddress.address1));
|
|
onChange(createSyntheticEvent("city", parsedAddress.city));
|
|
onChange(createSyntheticEvent("state", parsedAddress.state));
|
|
onChange(createSyntheticEvent("zipCode", parsedAddress.zipCode));
|
|
onChange(createSyntheticEvent("country", parsedAddress.country));
|
|
|
|
// Set coordinates immediately
|
|
if (onCoordinatesChange) {
|
|
onCoordinatesChange(parsedAddress.latitude, parsedAddress.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, parsePlace]
|
|
);
|
|
|
|
// Handle Places API errors
|
|
const handlePlacesApiError = useCallback(() => {
|
|
setPlacesApiError(true);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="card mb-4">
|
|
<div className="card-body">
|
|
<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.
|
|
</small>
|
|
</div>
|
|
|
|
{addressesLoading ? (
|
|
<div className="text-center py-3">
|
|
<div className="spinner-border spinner-border-sm" role="status">
|
|
<span className="visually-hidden">Loading addresses...</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Multiple addresses - show dropdown */}
|
|
{userAddresses.length > 1 && (
|
|
<div className="mb-3">
|
|
<label className="form-label">Select Address</label>
|
|
<select
|
|
className="form-select"
|
|
value={selectedAddressId || "new"}
|
|
onChange={(e) => onAddressSelect(e.target.value)}
|
|
>
|
|
<option value="new">Enter new address</option>
|
|
{userAddresses.map((address) => (
|
|
<option key={address.id} value={address.id}>
|
|
{formatAddressDisplay(address)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Show form fields for all scenarios with addresses <= 1 or when "new" is selected */}
|
|
{(userAddresses.length <= 1 ||
|
|
(userAddresses.length > 1 && !selectedAddressId)) && (
|
|
<>
|
|
<div className="row mb-3">
|
|
<div className="col-md-6">
|
|
<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">
|
|
Address Line 2
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
id="address2"
|
|
name="address2"
|
|
value={data.address2}
|
|
onChange={onChange}
|
|
placeholder="Apt, Suite, Unit, etc."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row mb-3">
|
|
<div className="col-md-6">
|
|
<label htmlFor="city" className="form-label">
|
|
City *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
id="city"
|
|
name="city"
|
|
value={data.city}
|
|
onChange={onChange}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label htmlFor="state" className="form-label">
|
|
State *
|
|
</label>
|
|
<select
|
|
className="form-select"
|
|
id="state"
|
|
name="state"
|
|
value={data.state}
|
|
onChange={onChange}
|
|
required
|
|
>
|
|
<option value="">Select State</option>
|
|
{usStates.map((state) => (
|
|
<option key={state} value={state}>
|
|
{state}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label htmlFor="zipCode" className="form-label">
|
|
ZIP Code *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
id="zipCode"
|
|
name="zipCode"
|
|
value={data.zipCode}
|
|
onChange={onChange}
|
|
placeholder="12345"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LocationForm;
|