Gas Station Feature
This commit is contained in:
@@ -10,10 +10,11 @@ import { VehicleSelector } from './VehicleSelector';
|
||||
import { DistanceInput } from './DistanceInput';
|
||||
import { FuelTypeSelector } from './FuelTypeSelector';
|
||||
import { UnitSystemDisplay } from './UnitSystemDisplay';
|
||||
import { LocationInput } from './LocationInput';
|
||||
import { StationPicker } from './StationPicker';
|
||||
import { CostCalculator } from './CostCalculator';
|
||||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||||
import { useUserSettings } from '../hooks/useUserSettings';
|
||||
import { useGeolocation } from '../../stations/hooks/useGeolocation';
|
||||
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
||||
|
||||
const schema = z.object({
|
||||
@@ -39,6 +40,9 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
const [useOdometer, setUseOdometer] = useState(false);
|
||||
const formInitialized = useRef(false);
|
||||
|
||||
// Get user location for nearby station search
|
||||
const { coordinates: userLocation } = useGeolocation();
|
||||
|
||||
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
|
||||
resolver: zodResolver(schema),
|
||||
mode: 'onChange',
|
||||
@@ -282,7 +286,12 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="locationData" control={control} render={({ field }) => (
|
||||
<LocationInput value={field.value as any} onChange={field.onChange as any} placeholder="Station location (optional)" />
|
||||
<StationPicker
|
||||
value={field.value as any}
|
||||
onChange={field.onChange as any}
|
||||
userLocation={userLocation}
|
||||
placeholder="Station location (optional)"
|
||||
/>
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
|
||||
309
frontend/src/features/fuel-logs/components/StationPicker.tsx
Normal file
309
frontend/src/features/fuel-logs/components/StationPicker.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @ai-summary Autocomplete component for selecting gas stations
|
||||
* Integrates with saved stations and nearby search
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Bookmark as BookmarkIcon,
|
||||
LocationOn as LocationIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useSavedStations } from '../../stations/hooks/useSavedStations';
|
||||
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
|
||||
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
|
||||
import { LocationData } from '../types/fuel-logs.types';
|
||||
|
||||
interface StationPickerProps {
|
||||
/** Current location data value */
|
||||
value?: LocationData;
|
||||
/** Callback when station is selected */
|
||||
onChange: (value?: LocationData) => void;
|
||||
/** User's current location (optional) */
|
||||
userLocation?: GeolocationCoordinates | null;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StationOption {
|
||||
type: 'saved' | 'nearby' | 'manual';
|
||||
station?: Station | SavedStation;
|
||||
label: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance from meters to user-friendly string
|
||||
*/
|
||||
function formatDistance(meters: number): string {
|
||||
const miles = meters / 1609.34;
|
||||
if (miles < 0.1) return '< 0.1 mi';
|
||||
if (miles < 10) return `${miles.toFixed(1)} mi`;
|
||||
return `${Math.round(miles)} mi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if station is saved
|
||||
*/
|
||||
function isSavedStation(station: Station | SavedStation): station is SavedStation {
|
||||
return 'userId' in station;
|
||||
}
|
||||
|
||||
/**
|
||||
* StationPicker Component
|
||||
*
|
||||
* Autocomplete component that allows users to:
|
||||
* - Select from saved stations
|
||||
* - Search nearby stations (if location available)
|
||||
* - Enter manual text input
|
||||
*
|
||||
* Features:
|
||||
* - Grouped options (Saved / Nearby)
|
||||
* - Debounced search (300ms)
|
||||
* - Loading indicators
|
||||
* - Fallback to text input on API failure
|
||||
* - Distance display
|
||||
* - Bookmark icons for saved stations
|
||||
*/
|
||||
export const StationPicker: React.FC<StationPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
userLocation,
|
||||
placeholder = 'Station location (optional)',
|
||||
error
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState(value?.stationName || '');
|
||||
const [searchTrigger, setSearchTrigger] = useState(0);
|
||||
|
||||
// Fetch saved stations
|
||||
const { data: savedStations, isPending: savedLoading } = useSavedStations();
|
||||
|
||||
// Search mutation for nearby stations
|
||||
const { mutate: searchStations, data: nearbyStations, isPending: searchLoading } = useStationsSearch();
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
if (!userLocation || !inputValue || inputValue.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setSearchTrigger((prev) => prev + 1);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [inputValue, userLocation]);
|
||||
|
||||
// Execute search when trigger changes
|
||||
useEffect(() => {
|
||||
if (searchTrigger > 0 && userLocation) {
|
||||
searchStations({
|
||||
latitude: userLocation.latitude,
|
||||
longitude: userLocation.longitude,
|
||||
radius: 8000 // 5 miles in meters
|
||||
});
|
||||
}
|
||||
}, [searchTrigger, userLocation, searchStations]);
|
||||
|
||||
// Build options list
|
||||
const options: StationOption[] = useMemo(() => {
|
||||
const opts: StationOption[] = [];
|
||||
|
||||
// Add saved stations first
|
||||
if (savedStations && savedStations.length > 0) {
|
||||
savedStations.forEach((station) => {
|
||||
opts.push({
|
||||
type: 'saved',
|
||||
station,
|
||||
label: station.nickname || station.name,
|
||||
group: 'Saved Stations'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add nearby stations
|
||||
if (nearbyStations && nearbyStations.length > 0) {
|
||||
// Filter out stations already in saved list
|
||||
const savedPlaceIds = new Set(savedStations?.map((s) => s.placeId) || []);
|
||||
|
||||
nearbyStations
|
||||
.filter((station) => !savedPlaceIds.has(station.placeId))
|
||||
.forEach((station) => {
|
||||
opts.push({
|
||||
type: 'nearby',
|
||||
station,
|
||||
label: station.name,
|
||||
group: 'Nearby Stations'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return opts;
|
||||
}, [savedStations, nearbyStations]);
|
||||
|
||||
// Handle option selection
|
||||
const handleChange = useCallback(
|
||||
(_event: React.SyntheticEvent, newValue: StationOption | string | null) => {
|
||||
if (!newValue) {
|
||||
onChange(undefined);
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Manual text input (freeSolo)
|
||||
if (typeof newValue === 'string') {
|
||||
onChange({
|
||||
stationName: newValue
|
||||
});
|
||||
setInputValue(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Selected from options
|
||||
const { station } = newValue;
|
||||
if (station) {
|
||||
onChange({
|
||||
stationName: station.name,
|
||||
address: station.address,
|
||||
googlePlaceId: station.placeId,
|
||||
coordinates: {
|
||||
latitude: station.latitude,
|
||||
longitude: station.longitude
|
||||
}
|
||||
});
|
||||
setInputValue(station.name);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Handle input text change
|
||||
const handleInputChange = useCallback((_event: React.SyntheticEvent, newInputValue: string) => {
|
||||
setInputValue(newInputValue);
|
||||
}, []);
|
||||
|
||||
// Custom option rendering
|
||||
const renderOption = useCallback(
|
||||
(props: React.HTMLAttributes<HTMLLIElement>, option: StationOption | string) => {
|
||||
// Handle manual text input option
|
||||
if (typeof option === 'string') {
|
||||
return (
|
||||
<li {...props}>
|
||||
<Typography variant="body2">{option}</Typography>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const { station, type } = option;
|
||||
if (!station) return null;
|
||||
|
||||
const isSaved = isSavedStation(station);
|
||||
const displayName = isSaved && station.nickname ? station.nickname : station.name;
|
||||
const distance = station.distance ? formatDistance(station.distance) : null;
|
||||
|
||||
return (
|
||||
<li {...props}>
|
||||
<Box display="flex" alignItems="center" width="100%" gap={1}>
|
||||
{type === 'saved' && (
|
||||
<BookmarkIcon fontSize="small" color="primary" />
|
||||
)}
|
||||
{type === 'nearby' && (
|
||||
<LocationIcon fontSize="small" color="action" />
|
||||
)}
|
||||
<Box flex={1} minWidth={0}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{distance && `${distance} • `}
|
||||
{station.address}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Group options by category
|
||||
const groupBy = useCallback((option: StationOption | string) => {
|
||||
if (typeof option === 'string') return '';
|
||||
return option.group;
|
||||
}, []);
|
||||
|
||||
// Get option label
|
||||
const getOptionLabel = useCallback((option: StationOption | string) => {
|
||||
if (typeof option === 'string') return option;
|
||||
return option.label;
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
const isLoading = savedLoading || searchLoading;
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={options}
|
||||
value={null} // Controlled by inputValue
|
||||
inputValue={inputValue}
|
||||
onChange={handleChange}
|
||||
onInputChange={handleInputChange}
|
||||
groupBy={groupBy}
|
||||
getOptionLabel={getOptionLabel}
|
||||
renderOption={renderOption}
|
||||
filterOptions={(opts) => opts} // Don't filter, we control options
|
||||
loading={isLoading}
|
||||
loadingText="Searching stations..."
|
||||
noOptionsText={
|
||||
userLocation
|
||||
? 'No stations found. Type to enter manually.'
|
||||
: 'Enable location to search nearby stations.'
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Location (optional)"
|
||||
placeholder={placeholder}
|
||||
error={!!error}
|
||||
helperText={error || (userLocation ? 'Search saved or nearby stations' : 'Type station name')}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{isLoading && (
|
||||
<InputAdornment position="end">
|
||||
<CircularProgress size={20} />
|
||||
</InputAdornment>
|
||||
)}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
'& .MuiAutocomplete-groupLabel': {
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'grey.100',
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
},
|
||||
'& .MuiAutocomplete-option': {
|
||||
minHeight: '44px', // Mobile touch target
|
||||
padding: '8px 16px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user