Files
rentall-app/frontend/src/components/LocationForm.tsx
2025-11-20 15:01:15 -05:00

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;