Make/Model Data Loading
This commit is contained in:
@@ -78,14 +78,14 @@ StationsMobileScreen (Mobile)
|
||||
|
||||
### StationsSearchForm
|
||||
|
||||
**Purpose**: Search input with geolocation and manual coordinate entry
|
||||
**Purpose**: Search input with geolocation or manual street-level address entry
|
||||
|
||||
**Props**: None (uses hooks internally)
|
||||
|
||||
**Features**:
|
||||
- Geolocation button (requests browser permission)
|
||||
- Manual latitude/longitude inputs
|
||||
- Radius slider (1-50 km)
|
||||
- Manual Street / City / State / ZIP inputs with Google geocoding
|
||||
- Radius slider (1-25 miles)
|
||||
- Loading states
|
||||
- Error handling
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @ai-summary Form for searching nearby gas stations
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
@@ -12,12 +12,95 @@ import {
|
||||
FormLabel,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
InputAdornment
|
||||
InputAdornment,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import LocationIcon from '@mui/icons-material/LocationOn';
|
||||
import MyLocationIcon from '@mui/icons-material/MyLocation';
|
||||
import { StationSearchRequest, GeolocationError } from '../types/stations.types';
|
||||
import { useGeolocation } from '../hooks';
|
||||
import { loadGoogleMaps, getGoogleMapsApi } from '../utils/maps-loader';
|
||||
|
||||
type Coordinates = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
type GeocoderStatus = 'OK' | 'ZERO_RESULTS' | string;
|
||||
|
||||
interface GeocoderResult {
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => number;
|
||||
lng: () => number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface GoogleMapsGeocoder {
|
||||
geocode(
|
||||
request: { address: string },
|
||||
callback: (results: GeocoderResult[] | null, status: GeocoderStatus) => void
|
||||
): void;
|
||||
}
|
||||
|
||||
type GoogleMapsWithGeocoder = {
|
||||
Geocoder: new () => GoogleMapsGeocoder;
|
||||
};
|
||||
|
||||
const US_STATE_OPTIONS = [
|
||||
{ value: 'AL', label: 'Alabama' },
|
||||
{ value: 'AK', label: 'Alaska' },
|
||||
{ value: 'AZ', label: 'Arizona' },
|
||||
{ value: 'AR', label: 'Arkansas' },
|
||||
{ value: 'CA', label: 'California' },
|
||||
{ value: 'CO', label: 'Colorado' },
|
||||
{ value: 'CT', label: 'Connecticut' },
|
||||
{ value: 'DE', label: 'Delaware' },
|
||||
{ value: 'DC', label: 'District of Columbia' },
|
||||
{ value: 'FL', label: 'Florida' },
|
||||
{ value: 'GA', label: 'Georgia' },
|
||||
{ value: 'HI', label: 'Hawaii' },
|
||||
{ value: 'ID', label: 'Idaho' },
|
||||
{ value: 'IL', label: 'Illinois' },
|
||||
{ value: 'IN', label: 'Indiana' },
|
||||
{ value: 'IA', label: 'Iowa' },
|
||||
{ value: 'KS', label: 'Kansas' },
|
||||
{ value: 'KY', label: 'Kentucky' },
|
||||
{ value: 'LA', label: 'Louisiana' },
|
||||
{ value: 'ME', label: 'Maine' },
|
||||
{ value: 'MD', label: 'Maryland' },
|
||||
{ value: 'MA', label: 'Massachusetts' },
|
||||
{ value: 'MI', label: 'Michigan' },
|
||||
{ value: 'MN', label: 'Minnesota' },
|
||||
{ value: 'MS', label: 'Mississippi' },
|
||||
{ value: 'MO', label: 'Missouri' },
|
||||
{ value: 'MT', label: 'Montana' },
|
||||
{ value: 'NE', label: 'Nebraska' },
|
||||
{ value: 'NV', label: 'Nevada' },
|
||||
{ value: 'NH', label: 'New Hampshire' },
|
||||
{ value: 'NJ', label: 'New Jersey' },
|
||||
{ value: 'NM', label: 'New Mexico' },
|
||||
{ value: 'NY', label: 'New York' },
|
||||
{ value: 'NC', label: 'North Carolina' },
|
||||
{ value: 'ND', label: 'North Dakota' },
|
||||
{ value: 'OH', label: 'Ohio' },
|
||||
{ value: 'OK', label: 'Oklahoma' },
|
||||
{ value: 'OR', label: 'Oregon' },
|
||||
{ value: 'PA', label: 'Pennsylvania' },
|
||||
{ value: 'RI', label: 'Rhode Island' },
|
||||
{ value: 'SC', label: 'South Carolina' },
|
||||
{ value: 'SD', label: 'South Dakota' },
|
||||
{ value: 'TN', label: 'Tennessee' },
|
||||
{ value: 'TX', label: 'Texas' },
|
||||
{ value: 'UT', label: 'Utah' },
|
||||
{ value: 'VT', label: 'Vermont' },
|
||||
{ value: 'VA', label: 'Virginia' },
|
||||
{ value: 'WA', label: 'Washington' },
|
||||
{ value: 'WV', label: 'West Virginia' },
|
||||
{ value: 'WI', label: 'Wisconsin' },
|
||||
{ value: 'WY', label: 'Wyoming' }
|
||||
];
|
||||
|
||||
interface StationsSearchFormProps {
|
||||
onSearch: (request: StationSearchRequest) => void;
|
||||
@@ -32,10 +115,15 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
onSearch,
|
||||
isSearching = false
|
||||
}) => {
|
||||
const [latitude, setLatitude] = useState<number | ''>('');
|
||||
const [longitude, setLongitude] = useState<number | ''>('');
|
||||
const [street, setStreet] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [stateCode, setStateCode] = useState('');
|
||||
const [zip, setZip] = useState('');
|
||||
const [radius, setRadius] = useState(5); // Miles
|
||||
const [locationError, setLocationError] = useState<string | null>(null);
|
||||
const [addressError, setAddressError] = useState<string | null>(null);
|
||||
const [resolvedCoordinates, setResolvedCoordinates] = useState<Coordinates | null>(null);
|
||||
const [isGeocoding, setIsGeocoding] = useState(false);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
@@ -48,9 +136,12 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
// Update form when geolocation succeeds
|
||||
useEffect(() => {
|
||||
if (coordinates) {
|
||||
setLatitude(coordinates.latitude);
|
||||
setLongitude(coordinates.longitude);
|
||||
setResolvedCoordinates({
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude
|
||||
});
|
||||
setLocationError(null);
|
||||
setAddressError(null);
|
||||
}
|
||||
}, [coordinates]);
|
||||
|
||||
@@ -64,29 +155,85 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
} else if (geoError === GeolocationError.POSITION_UNAVAILABLE) {
|
||||
setLocationError('Location not available. Try a different device.');
|
||||
} else {
|
||||
setLocationError('Unable to get location. Please enter manually.');
|
||||
setLocationError('Unable to get location. Please enter your address.');
|
||||
}
|
||||
}
|
||||
}, [geoError]);
|
||||
|
||||
const handleUseCurrentLocation = () => {
|
||||
clearGeoError();
|
||||
setAddressError(null);
|
||||
requestPermission();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (latitude === '' || longitude === '') {
|
||||
setLocationError('Please enter coordinates or use current location');
|
||||
const addressIsComplete = useMemo(
|
||||
() => street.trim() !== '' && city.trim() !== '' && stateCode !== '' && zip.trim().length === 5,
|
||||
[street, city, stateCode, zip]
|
||||
);
|
||||
|
||||
const markManualAddressInput = () => {
|
||||
setResolvedCoordinates(null);
|
||||
setAddressError(null);
|
||||
setLocationError(null);
|
||||
};
|
||||
|
||||
const handleSearch = async (): Promise<void> => {
|
||||
const submitWithCoordinates = (coords: Coordinates) => {
|
||||
const request: StationSearchRequest = {
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
radius: radius * 1609.34 // Convert miles to meters
|
||||
};
|
||||
|
||||
onSearch(request);
|
||||
};
|
||||
|
||||
if (resolvedCoordinates) {
|
||||
submitWithCoordinates(resolvedCoordinates);
|
||||
return;
|
||||
}
|
||||
|
||||
const request: StationSearchRequest = {
|
||||
latitude: typeof latitude === 'number' ? latitude : 0,
|
||||
longitude: typeof longitude === 'number' ? longitude : 0,
|
||||
radius: radius * 1609.34 // Convert miles to meters
|
||||
};
|
||||
if (!addressIsComplete) {
|
||||
setAddressError('Enter Street, City, State, and ZIP or use current location.');
|
||||
return;
|
||||
}
|
||||
|
||||
onSearch(request);
|
||||
try {
|
||||
setIsGeocoding(true);
|
||||
await loadGoogleMaps();
|
||||
const maps = getGoogleMapsApi() as unknown as GoogleMapsWithGeocoder;
|
||||
const geocoder = new maps.Geocoder();
|
||||
const formattedAddress = [street, city, stateCode, zip].filter(Boolean).join(', ');
|
||||
|
||||
const coords = await new Promise<Coordinates>((resolve, reject) => {
|
||||
geocoder.geocode({ address: formattedAddress }, (results, status) => {
|
||||
if (status === 'OK' && results && results[0]) {
|
||||
const location = results[0].geometry.location;
|
||||
resolve({
|
||||
latitude: location.lat(),
|
||||
longitude: location.lng()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'ZERO_RESULTS') {
|
||||
reject(new Error('Address not found. Please double-check the details.'));
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Unable to locate that address right now. Try again shortly.'));
|
||||
});
|
||||
});
|
||||
|
||||
setResolvedCoordinates(coords);
|
||||
setLocationError(null);
|
||||
setAddressError(null);
|
||||
submitWithCoordinates(coords);
|
||||
} catch (error) {
|
||||
setAddressError(error instanceof Error ? error.message : 'Unable to locate that address.');
|
||||
} finally {
|
||||
setIsGeocoding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRadiusChange = (
|
||||
@@ -103,7 +250,7 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
component="form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
void handleSearch();
|
||||
}}
|
||||
sx={{ padding: 2, display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
@@ -121,17 +268,17 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
{/* Or Divider */}
|
||||
<Box sx={{ textAlign: 'center', color: 'textSecondary' }}>or</Box>
|
||||
|
||||
{/* Manual Latitude Input */}
|
||||
{/* Street Address Input */}
|
||||
<TextField
|
||||
label="Latitude"
|
||||
type="number"
|
||||
value={latitude}
|
||||
label="Street"
|
||||
name="street"
|
||||
value={street}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setLatitude(val === '' ? '' : parseFloat(val));
|
||||
setStreet(e.target.value);
|
||||
markManualAddressInput();
|
||||
}}
|
||||
placeholder="37.7749"
|
||||
inputProps={{ step: '0.0001', min: '-90', max: '90' }}
|
||||
placeholder="123 Main St"
|
||||
autoComplete="address-line1"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
@@ -142,31 +289,87 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Manual Longitude Input */}
|
||||
{/* City Input */}
|
||||
<TextField
|
||||
label="Longitude"
|
||||
type="number"
|
||||
value={longitude}
|
||||
label="City"
|
||||
name="city"
|
||||
value={city}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setLongitude(val === '' ? '' : parseFloat(val));
|
||||
setCity(e.target.value);
|
||||
markManualAddressInput();
|
||||
}}
|
||||
placeholder="-122.4194"
|
||||
inputProps={{ step: '0.0001', min: '-180', max: '180' }}
|
||||
placeholder="San Francisco"
|
||||
autoComplete="address-level2"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<LocationIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* State and ZIP */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
|
||||
gap: 2,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
label="State"
|
||||
value={stateCode}
|
||||
onChange={(e) => {
|
||||
setStateCode(e.target.value);
|
||||
markManualAddressInput();
|
||||
}}
|
||||
SelectProps={{
|
||||
displayEmpty: true,
|
||||
renderValue: (selected) => {
|
||||
const value = selected as string;
|
||||
if (!value) {
|
||||
return 'Select state';
|
||||
}
|
||||
const option = US_STATE_OPTIONS.find((state) => state.value === value);
|
||||
return option ? option.label : value;
|
||||
}
|
||||
}}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ name: 'state' }}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Select state</em>
|
||||
</MenuItem>
|
||||
{US_STATE_OPTIONS.map((state) => (
|
||||
<MenuItem key={state.value} value={state.value}>
|
||||
{state.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="ZIP"
|
||||
name="zip"
|
||||
value={zip}
|
||||
onChange={(e) => {
|
||||
const sanitized = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
|
||||
setZip(sanitized);
|
||||
markManualAddressInput();
|
||||
}}
|
||||
placeholder="94105"
|
||||
inputProps={{
|
||||
inputMode: 'numeric',
|
||||
pattern: '[0-9]*',
|
||||
maxLength: 5
|
||||
}}
|
||||
autoComplete="postal-code"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Radius Slider */}
|
||||
<FormControl fullWidth>
|
||||
<FormLabel>Search Radius: {radius} mi</FormLabel>
|
||||
<Slider
|
||||
data-testid="radius-slider"
|
||||
value={radius}
|
||||
onChange={handleRadiusChange}
|
||||
min={1}
|
||||
@@ -183,22 +386,32 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
</FormControl>
|
||||
|
||||
{/* Error Messages */}
|
||||
{locationError && (
|
||||
<Alert severity="error">{locationError}</Alert>
|
||||
{(locationError || addressError) && (
|
||||
<Alert severity="error">{locationError || addressError}</Alert>
|
||||
)}
|
||||
|
||||
{/* Search Button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || latitude === '' || longitude === ''}
|
||||
onClick={() => {
|
||||
void handleSearch();
|
||||
}}
|
||||
disabled={
|
||||
isSearching ||
|
||||
isGeocoding ||
|
||||
(!resolvedCoordinates && !addressIsComplete)
|
||||
}
|
||||
sx={{
|
||||
minHeight: '44px',
|
||||
marginTop: 1
|
||||
}}
|
||||
>
|
||||
{isSearching ? <CircularProgress size={24} /> : 'Search Stations'}
|
||||
{isSearching || isGeocoding ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
'Search Stations'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user