Gas Station Feature
This commit is contained in:
@@ -26,6 +26,8 @@ const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage'
|
||||
const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPage').then(m => ({ default: m.DocumentsPage })));
|
||||
const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage })));
|
||||
const MaintenancePage = lazy(() => import('./features/maintenance/pages/MaintenancePage').then(m => ({ default: m.MaintenancePage })));
|
||||
const StationsPage = lazy(() => import('./features/stations/pages/StationsPage').then(m => ({ default: m.StationsPage })));
|
||||
const StationsMobileScreen = lazy(() => import('./features/stations/mobile/StationsMobileScreen').then(m => ({ default: m.default })));
|
||||
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
|
||||
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
|
||||
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
|
||||
@@ -148,7 +150,7 @@ const LogFuelScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MobileErrorBoundary screenName="FuelLogForm" key="fuel-form">
|
||||
<MobileErrorBoundary screenName="FuelLogForm">
|
||||
<FuelLogForm onSuccess={() => {
|
||||
// Refresh dependent data
|
||||
try {
|
||||
@@ -163,7 +165,7 @@ const LogFuelScreen: React.FC = () => {
|
||||
}
|
||||
}} />
|
||||
</MobileErrorBoundary>
|
||||
<MobileErrorBoundary screenName="FuelLogsSection" key="fuel-section">
|
||||
<MobileErrorBoundary screenName="FuelLogsSection">
|
||||
<GlassCard>
|
||||
<div className="py-2">
|
||||
{isLoading ? (
|
||||
@@ -181,7 +183,7 @@ const LogFuelScreen: React.FC = () => {
|
||||
</GlassCard>
|
||||
</MobileErrorBoundary>
|
||||
|
||||
<MobileErrorBoundary screenName="FuelLogEditDialog" key="fuel-edit-dialog">
|
||||
<MobileErrorBoundary screenName="FuelLogEditDialog">
|
||||
<FuelLogEditDialog
|
||||
open={!!editingLog}
|
||||
log={editingLog}
|
||||
@@ -308,6 +310,7 @@ function App() {
|
||||
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
|
||||
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> },
|
||||
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> },
|
||||
{ key: "Stations", label: "Stations", icon: <LocalGasStationRoundedIcon /> },
|
||||
{ key: "Documents", label: "Documents", icon: <DescriptionRoundedIcon /> },
|
||||
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
|
||||
];
|
||||
@@ -479,6 +482,31 @@ function App() {
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Stations" && (
|
||||
<motion.div
|
||||
key="stations"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Stations">
|
||||
<React.Suspense fallback={
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="text-slate-500 py-6 text-center">
|
||||
Loading stations screen...
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
}>
|
||||
<StationsMobileScreen />
|
||||
</React.Suspense>
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DebugInfo />
|
||||
</Layout>
|
||||
@@ -523,7 +551,7 @@ function App() {
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailPage />} />
|
||||
<Route path="/maintenance" element={<MaintenancePage />} />
|
||||
<Route path="/stations" element={<div>Stations (TODO)</div>} />
|
||||
<Route path="/stations" element={<StationsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/vehicles" replace />} />
|
||||
</Routes>
|
||||
|
||||
71
frontend/src/core/config/config.types.ts
Normal file
71
frontend/src/core/config/config.types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Runtime Configuration Types
|
||||
*
|
||||
* Configuration loaded at container startup from secrets.
|
||||
* Mirrors Kubernetes deployment patterns where secrets are mounted as files
|
||||
* and read at runtime before application initialization.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Application runtime configuration
|
||||
* Loaded from window.CONFIG set by /config.js
|
||||
*/
|
||||
export interface AppConfig {
|
||||
/** Google Maps JavaScript API key for map visualization */
|
||||
googleMapsApiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window augmentation for runtime config
|
||||
* config.js is loaded before the React app initializes
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
CONFIG?: AppConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application configuration with validation
|
||||
* Throws if required configuration is missing
|
||||
*
|
||||
* @returns Application configuration object
|
||||
* @throws Error if configuration is not loaded or invalid
|
||||
*/
|
||||
export function getConfig(): AppConfig {
|
||||
if (!window.CONFIG) {
|
||||
throw new Error(
|
||||
'Application configuration not loaded. Ensure config.js is loaded before the app initializes.'
|
||||
);
|
||||
}
|
||||
|
||||
return window.CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Maps API key
|
||||
* Returns empty string if key is not available (graceful fallback)
|
||||
*
|
||||
* @returns Google Maps API key or empty string
|
||||
*/
|
||||
export function getGoogleMapsApiKey(): string {
|
||||
try {
|
||||
const config = getConfig();
|
||||
return config.googleMapsApiKey || '';
|
||||
} catch {
|
||||
console.warn(
|
||||
'Google Maps API key not available. Maps functionality will be limited.'
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if configuration is available
|
||||
* Useful for conditional feature enablement
|
||||
*
|
||||
* @returns true if config is loaded, false otherwise
|
||||
*/
|
||||
export function isConfigLoaded(): boolean {
|
||||
return !!window.CONFIG;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { safeStorage } from '../utils/safe-storage';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Documents' | 'Settings';
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
924
frontend/src/features/stations/README.md
Normal file
924
frontend/src/features/stations/README.md
Normal file
@@ -0,0 +1,924 @@
|
||||
# Gas Stations Feature - Frontend Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Complete frontend implementation for the Gas Stations feature. This feature enables users to search for nearby gas stations, view them on an interactive map, save favorites with custom notes, and integrate station data into fuel logging workflows.
|
||||
|
||||
## Feature Capabilities
|
||||
|
||||
- Search nearby gas stations using geolocation or manual coordinates
|
||||
- View stations on interactive Google Maps
|
||||
- Display station cards with name, address, distance, rating
|
||||
- Save favorite stations with custom nicknames and notes
|
||||
- Mobile-first responsive design with tab navigation
|
||||
- Desktop layout with side-by-side map and list
|
||||
- Integration with fuel logs (StationPicker component)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
frontend/src/features/stations/
|
||||
├── types/ # TypeScript definitions
|
||||
│ └── stations.types.ts # API types, domain models
|
||||
├── api/ # API client
|
||||
│ └── stations.api.ts # HTTP calls to backend
|
||||
├── hooks/ # React Query hooks
|
||||
│ ├── useStationsSearch.ts # Search mutation
|
||||
│ ├── useSavedStations.ts # Get saved stations query
|
||||
│ ├── useSaveStation.ts # Save mutation
|
||||
│ ├── useDeleteStation.ts # Delete mutation
|
||||
│ └── useGeolocation.ts # Browser geolocation
|
||||
├── utils/ # Utility functions
|
||||
│ ├── distance.ts # Distance calculations
|
||||
│ ├── maps-loader.ts # Lazy-load Google Maps API
|
||||
│ └── map-utils.ts # Map helpers
|
||||
├── components/ # React components
|
||||
│ ├── StationCard.tsx # Individual station display
|
||||
│ ├── StationsList.tsx # List of search results
|
||||
│ ├── SavedStationsList.tsx # Saved stations list
|
||||
│ ├── StationsSearchForm.tsx # Search input form
|
||||
│ ├── StationMap.tsx # Interactive Google Map
|
||||
│ └── index.ts # Component exports
|
||||
├── pages/ # Page layouts
|
||||
│ └── StationsPage.tsx # Desktop layout
|
||||
├── mobile/ # Mobile layouts
|
||||
│ └── StationsMobileScreen.tsx # Mobile tab navigation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
StationsPage (Desktop)
|
||||
├── StationsSearchForm
|
||||
│ └── useGeolocation
|
||||
│ └── useStationsSearch
|
||||
├── StationMap
|
||||
│ └── Google Maps API
|
||||
│ └── Station markers
|
||||
└── StationsList
|
||||
└── StationCard (multiple)
|
||||
└── useSaveStation
|
||||
└── useDeleteStation
|
||||
|
||||
StationsMobileScreen (Mobile)
|
||||
├── Tab: Search
|
||||
│ ├── StationsSearchForm
|
||||
│ └── StationsList
|
||||
├── Tab: Saved
|
||||
│ └── SavedStationsList
|
||||
│ └── StationCard (multiple)
|
||||
└── Tab: Map
|
||||
└── StationMap
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### StationsSearchForm
|
||||
|
||||
**Purpose**: Search input with geolocation and manual coordinate entry
|
||||
|
||||
**Props**: None (uses hooks internally)
|
||||
|
||||
**Features**:
|
||||
- Geolocation button (requests browser permission)
|
||||
- Manual latitude/longitude inputs
|
||||
- Radius slider (1-50 km)
|
||||
- Loading states
|
||||
- Error handling
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
import { StationsSearchForm } from '@/features/stations/components';
|
||||
|
||||
function MyPage() {
|
||||
return <StationsSearchForm />;
|
||||
}
|
||||
```
|
||||
|
||||
**Hooks Used**:
|
||||
- `useGeolocation()` - Browser geolocation API
|
||||
- `useStationsSearch()` - Search mutation
|
||||
|
||||
**State Management**:
|
||||
- Form state via React hooks
|
||||
- Search results via React Query cache
|
||||
|
||||
### StationCard
|
||||
|
||||
**Purpose**: Display individual station with actions
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface StationCardProps {
|
||||
station: Station;
|
||||
distance?: number;
|
||||
isSaved?: boolean;
|
||||
onSave?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Station name, address, rating display
|
||||
- Distance badge (if provided)
|
||||
- Save/unsave button (heart icon)
|
||||
- Directions link to Google Maps
|
||||
- Touch-friendly 44px button heights
|
||||
- Responsive layout
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
import { StationCard } from '@/features/stations/components';
|
||||
|
||||
function MyComponent() {
|
||||
const { data: stations } = useStationsSearch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{stations?.map(station => (
|
||||
<StationCard
|
||||
key={station.placeId}
|
||||
station={station}
|
||||
distance={station.distance}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### StationMap
|
||||
|
||||
**Purpose**: Interactive Google Map with station markers
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface StationMapProps {
|
||||
stations: Station[];
|
||||
center?: { lat: number; lng: number };
|
||||
zoom?: number;
|
||||
onStationClick?: (station: Station) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Lazy-load Google Maps API (via maps-loader.ts)
|
||||
- Auto-fit bounds to show all stations
|
||||
- Custom markers for gas stations
|
||||
- Click handler for station selection
|
||||
- Loading fallback UI
|
||||
- Error handling (API key issues)
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
import { StationMap } from '@/features/stations/components';
|
||||
|
||||
function MyPage() {
|
||||
const { data: stations } = useStationsSearch();
|
||||
|
||||
return (
|
||||
<StationMap
|
||||
stations={stations || []}
|
||||
onStationClick={(station) => {
|
||||
console.log('Selected:', station);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Google Maps API Loading**:
|
||||
The map component uses `maps-loader.ts` to lazy-load the Google Maps JavaScript API:
|
||||
```typescript
|
||||
import { loadGoogleMaps } from '@/features/stations/utils/maps-loader';
|
||||
|
||||
useEffect(() => {
|
||||
loadGoogleMaps()
|
||||
.then(() => {
|
||||
// Initialize map
|
||||
})
|
||||
.catch((error) => {
|
||||
// Handle error
|
||||
});
|
||||
}, []);
|
||||
```
|
||||
|
||||
### StationsList
|
||||
|
||||
**Purpose**: Scrollable list of station cards
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface StationsListProps {
|
||||
stations: Station[];
|
||||
isLoading?: boolean;
|
||||
onStationSelect?: (station: Station) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Virtualized list for performance (if many results)
|
||||
- Empty state messaging
|
||||
- Loading skeletons
|
||||
- Responsive grid layout
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
import { StationsList } from '@/features/stations/components';
|
||||
|
||||
function MyComponent() {
|
||||
const { data: stations, isLoading } = useStationsSearch();
|
||||
|
||||
return (
|
||||
<StationsList
|
||||
stations={stations || []}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### SavedStationsList
|
||||
|
||||
**Purpose**: List of user's saved favorite stations
|
||||
|
||||
**Props**: None (uses hooks internally)
|
||||
|
||||
**Features**:
|
||||
- Fetches saved stations on mount
|
||||
- Edit nickname/notes inline
|
||||
- Delete confirmation
|
||||
- Empty state for no saved stations
|
||||
- Pull-to-refresh (mobile)
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
import { SavedStationsList } from '@/features/stations/components';
|
||||
|
||||
function SavedTab() {
|
||||
return <SavedStationsList />;
|
||||
}
|
||||
```
|
||||
|
||||
**Hooks Used**:
|
||||
- `useSavedStations()` - Fetch saved stations
|
||||
- `useDeleteStation()` - Remove saved station
|
||||
|
||||
## React Query Hooks
|
||||
|
||||
### useStationsSearch
|
||||
|
||||
**Purpose**: Search for nearby gas stations
|
||||
|
||||
**Type**: Mutation (not cached, each search is independent)
|
||||
|
||||
**Parameters**:
|
||||
```typescript
|
||||
interface StationSearchRequest {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
radius?: number; // In meters, default 5000
|
||||
fuelType?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
mutate: (request: StationSearchRequest) => void;
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error: ApiError | null;
|
||||
data: Station[] | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
const { mutate: search, isPending, data, error } = useStationsSearch({
|
||||
onSuccess: (stations) => {
|
||||
console.log('Found stations:', stations);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Search failed:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
search({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### useSavedStations
|
||||
|
||||
**Purpose**: Fetch all saved stations for current user
|
||||
|
||||
**Type**: Query (cached with 5-minute stale time)
|
||||
|
||||
**Parameters**: None (user identified by JWT)
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
data: SavedStation[] | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: ApiError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
const { data: savedStations, isLoading, refetch } = useSavedStations();
|
||||
|
||||
useEffect(() => {
|
||||
if (savedStations) {
|
||||
console.log('User has', savedStations.length, 'saved stations');
|
||||
}
|
||||
}, [savedStations]);
|
||||
```
|
||||
|
||||
### useSaveStation
|
||||
|
||||
**Purpose**: Save a station to favorites
|
||||
|
||||
**Type**: Mutation (invalidates saved stations cache)
|
||||
|
||||
**Parameters**:
|
||||
```typescript
|
||||
interface SaveStationRequest {
|
||||
placeId: string;
|
||||
nickname?: string;
|
||||
notes?: string;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
mutate: (request: SaveStationRequest) => void;
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error: ApiError | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
const { mutate: saveStation, isPending } = useSaveStation({
|
||||
onSuccess: () => {
|
||||
toast.success('Station saved!');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to save: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = (station: Station) => {
|
||||
saveStation({
|
||||
placeId: station.placeId,
|
||||
nickname: 'My Favorite Station',
|
||||
isFavorite: true
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### useDeleteStation
|
||||
|
||||
**Purpose**: Remove saved station from favorites
|
||||
|
||||
**Type**: Mutation (invalidates saved stations cache)
|
||||
|
||||
**Parameters**: `placeId: string`
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
mutate: (placeId: string) => void;
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error: ApiError | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
const { mutate: deleteStation, isPending } = useDeleteStation({
|
||||
onSuccess: () => {
|
||||
toast.success('Station removed');
|
||||
}
|
||||
});
|
||||
|
||||
const handleDelete = (placeId: string) => {
|
||||
if (confirm('Remove this station?')) {
|
||||
deleteStation(placeId);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### useGeolocation
|
||||
|
||||
**Purpose**: Access browser geolocation API
|
||||
|
||||
**Type**: Hook (not React Query, custom hook)
|
||||
|
||||
**Parameters**: None
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
location: { latitude: number; longitude: number } | null;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
requestLocation: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
const { location, error, isLoading, requestLocation } = useGeolocation();
|
||||
|
||||
const handleUseCurrentLocation = () => {
|
||||
requestLocation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
console.log('User location:', location);
|
||||
// Trigger search with location
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
if (error) {
|
||||
return <div>Geolocation error: {error}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Browser Permissions**:
|
||||
- Requests `navigator.geolocation.getCurrentPosition`
|
||||
- User must grant permission in browser
|
||||
- Fallback to manual coordinates if denied
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### distance.ts
|
||||
|
||||
**Purpose**: Calculate distance between two coordinates
|
||||
|
||||
**Functions**:
|
||||
```typescript
|
||||
/**
|
||||
* Calculate distance using Haversine formula
|
||||
* @param lat1 First point latitude
|
||||
* @param lon1 First point longitude
|
||||
* @param lat2 Second point latitude
|
||||
* @param lon2 Second point longitude
|
||||
* @returns Distance in meters
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number;
|
||||
|
||||
/**
|
||||
* Format distance for display
|
||||
* @param meters Distance in meters
|
||||
* @returns Formatted string (e.g., "1.5 km", "350 m")
|
||||
*/
|
||||
export function formatDistance(meters: number): string;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
import { calculateDistance, formatDistance } from '@/features/stations/utils/distance';
|
||||
|
||||
const distanceInMeters = calculateDistance(
|
||||
37.7749, -122.4194, // San Francisco
|
||||
37.7849, -122.4094 // Station location
|
||||
);
|
||||
|
||||
const formatted = formatDistance(distanceInMeters); // "1.2 km"
|
||||
```
|
||||
|
||||
### maps-loader.ts
|
||||
|
||||
**Purpose**: Lazy-load Google Maps JavaScript API
|
||||
|
||||
**Functions**:
|
||||
```typescript
|
||||
/**
|
||||
* Load Google Maps API dynamically
|
||||
* Reads API key from runtime config
|
||||
* Only loads once (cached promise)
|
||||
*/
|
||||
export function loadGoogleMaps(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if Google Maps API is already loaded
|
||||
*/
|
||||
export function isGoogleMapsLoaded(): boolean;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
import { loadGoogleMaps } from '@/features/stations/utils/maps-loader';
|
||||
|
||||
useEffect(() => {
|
||||
loadGoogleMaps()
|
||||
.then(() => {
|
||||
// google.maps is now available
|
||||
const map = new google.maps.Map(mapRef.current, {
|
||||
center: { lat: 37.7749, lng: -122.4194 },
|
||||
zoom: 12
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load Google Maps:', error);
|
||||
});
|
||||
}, []);
|
||||
```
|
||||
|
||||
**Runtime Configuration**:
|
||||
The loader uses the runtime config pattern to access the API key:
|
||||
```typescript
|
||||
import { getGoogleMapsApiKey } from '@/core/config/config.types';
|
||||
|
||||
const apiKey = getGoogleMapsApiKey();
|
||||
// Loads script: https://maps.googleapis.com/maps/api/js?key=...&libraries=places
|
||||
```
|
||||
|
||||
### map-utils.ts
|
||||
|
||||
**Purpose**: Helper functions for map operations
|
||||
|
||||
**Functions**:
|
||||
```typescript
|
||||
/**
|
||||
* Fit map bounds to show all stations
|
||||
*/
|
||||
export function fitBoundsToStations(
|
||||
map: google.maps.Map,
|
||||
stations: Station[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Create marker for station
|
||||
*/
|
||||
export function createStationMarker(
|
||||
map: google.maps.Map,
|
||||
station: Station,
|
||||
onClick?: (station: Station) => void
|
||||
): google.maps.Marker;
|
||||
```
|
||||
|
||||
## Runtime Configuration
|
||||
|
||||
The Gas Stations feature uses MotoVaultPro's K8s-aligned runtime configuration pattern for the Google Maps API key.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Container Startup**: `/app/load-config.sh` reads `/run/secrets/google-maps-api-key`
|
||||
2. **Config Generation**: Creates `/usr/share/nginx/html/config.js`
|
||||
3. **App Access**: React app reads `window.CONFIG.googleMapsApiKey`
|
||||
|
||||
### Accessing Configuration
|
||||
|
||||
```typescript
|
||||
import { getGoogleMapsApiKey } from '@/core/config/config.types';
|
||||
|
||||
export function MyComponent() {
|
||||
const apiKey = getGoogleMapsApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return <div>Google Maps API key not configured</div>;
|
||||
}
|
||||
|
||||
// Use API key
|
||||
return <MapComponent apiKey={apiKey} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
|
||||
For local development (Vite dev server):
|
||||
|
||||
```bash
|
||||
# Set up secrets
|
||||
mkdir -p ./secrets/app
|
||||
echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
|
||||
|
||||
# Alternatively, set environment variable
|
||||
export VITE_GOOGLE_MAPS_API_KEY=YOUR_API_KEY
|
||||
```
|
||||
|
||||
See `/frontend/docs/RUNTIME-CONFIG.md` for complete documentation.
|
||||
|
||||
## Adding New Functionality
|
||||
|
||||
### Adding a New Component
|
||||
|
||||
1. Create component in `components/` directory
|
||||
2. Follow naming convention: `ComponentName.tsx`
|
||||
3. Export from `components/index.ts`
|
||||
4. Add types to `types/stations.types.ts` if needed
|
||||
5. Write unit tests in `__tests__/` directory
|
||||
|
||||
**Example**:
|
||||
```tsx
|
||||
// components/StationRating.tsx
|
||||
import { Station } from '../types/stations.types';
|
||||
|
||||
interface StationRatingProps {
|
||||
station: Station;
|
||||
}
|
||||
|
||||
export function StationRating({ station }: StationRatingProps) {
|
||||
if (!station.rating) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-yellow-500">★</span>
|
||||
<span>{station.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// components/index.ts
|
||||
export { StationRating } from './StationRating';
|
||||
```
|
||||
|
||||
### Adding a New Hook
|
||||
|
||||
1. Create hook in `hooks/` directory
|
||||
2. Use React Query for server state
|
||||
3. Follow naming convention: `useFeatureName.ts`
|
||||
4. Document with JSDoc comments and usage examples
|
||||
|
||||
**Example**:
|
||||
```tsx
|
||||
// hooks/useStationDetails.ts
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { stationsApi } from '../api/stations.api';
|
||||
|
||||
/**
|
||||
* Fetch detailed information for a specific station
|
||||
* Cached for 10 minutes
|
||||
*/
|
||||
export function useStationDetails(placeId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['station', placeId],
|
||||
queryFn: () => stationsApi.getStationDetails(placeId),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
enabled: !!placeId
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. Add method to `api/stations.api.ts`
|
||||
2. Add types to `types/stations.types.ts`
|
||||
3. Create hook in `hooks/` directory
|
||||
4. Update documentation
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// api/stations.api.ts
|
||||
export const stationsApi = {
|
||||
// ... existing methods
|
||||
|
||||
async getNearbyPrices(placeId: string): Promise<FuelPrices> {
|
||||
const response = await apiClient.get(`/api/stations/${placeId}/prices`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
// types/stations.types.ts
|
||||
export interface FuelPrices {
|
||||
regular: number;
|
||||
premium: number;
|
||||
diesel: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
// hooks/useStationPrices.ts
|
||||
export function useStationPrices(placeId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['station-prices', placeId],
|
||||
queryFn: () => stationsApi.getNearbyPrices(placeId),
|
||||
staleTime: 5 * 60 * 1000 // 5 minutes
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Components
|
||||
|
||||
### Unit Testing
|
||||
|
||||
**Location**: `__tests__/components/`
|
||||
|
||||
**Tools**: Vitest + React Testing Library
|
||||
|
||||
**Example**:
|
||||
```tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { StationCard } from '../components/StationCard';
|
||||
import { mockStations } from './fixtures';
|
||||
|
||||
describe('StationCard', () => {
|
||||
it('renders station information', () => {
|
||||
render(<StationCard station={mockStations[0]} />);
|
||||
|
||||
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
||||
expect(screen.getByText(/123 Main St/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSave when save button clicked', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<StationCard station={mockStations[0]} onSave={onSave} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Test complete workflows:
|
||||
|
||||
```tsx
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { StationsPage } from '../pages/StationsPage';
|
||||
|
||||
describe('StationsPage Integration', () => {
|
||||
it('searches and displays stations', async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StationsPage />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
// Fill search form
|
||||
const latInput = screen.getByLabelText(/latitude/i);
|
||||
fireEvent.change(latInput, { target: { value: '37.7749' } });
|
||||
|
||||
// Submit search
|
||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
||||
fireEvent.click(searchButton);
|
||||
|
||||
// Wait for results
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Shell Gas Station/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
- Google Maps API loaded only when map component mounts
|
||||
- Station images lazy-loaded with Intersection Observer
|
||||
- Route-based code splitting for page components
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
- Search results not cached (each search is independent)
|
||||
- Saved stations cached for 5 minutes
|
||||
- Google Maps API script cached by browser
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Limit Search Radius**: Default 5km, max 50km
|
||||
2. **Pagination**: Load first 20 results, paginate if needed
|
||||
3. **Virtual Scrolling**: For large result sets (100+ stations)
|
||||
4. **Debounce Search**: Wait 500ms after user stops typing
|
||||
5. **Memoize Calculations**: Use `useMemo` for distance calculations
|
||||
|
||||
**Example**:
|
||||
```tsx
|
||||
const sortedStations = useMemo(() => {
|
||||
if (!stations || !userLocation) return stations;
|
||||
|
||||
return stations
|
||||
.map(station => ({
|
||||
...station,
|
||||
distance: calculateDistance(
|
||||
userLocation.latitude,
|
||||
userLocation.longitude,
|
||||
station.latitude,
|
||||
station.longitude
|
||||
)
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance);
|
||||
}, [stations, userLocation]);
|
||||
```
|
||||
|
||||
## Mobile Responsiveness
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Mobile-first**: Design for mobile, enhance for desktop
|
||||
- **Touch targets**: Minimum 44px height for buttons
|
||||
- **Readable text**: Minimum 16px font size (no zoom on iOS)
|
||||
- **Accessible contrast**: WCAG AA compliance
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
```css
|
||||
/* Mobile (default) */
|
||||
@media (min-width: 640px) { /* sm */
|
||||
/* Small tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) { /* md */
|
||||
/* Tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) { /* lg */
|
||||
/* Desktop - switch to side-by-side layout */
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Differences
|
||||
|
||||
**Mobile** (< 1024px):
|
||||
- Bottom tab navigation (Search, Saved, Map)
|
||||
- Full-width components
|
||||
- Stack vertically
|
||||
|
||||
**Desktop** (>= 1024px):
|
||||
- Side-by-side: Map on left, List on right
|
||||
- Fixed positions with scroll
|
||||
- Larger interactive areas
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Google Maps Not Loading
|
||||
|
||||
**Symptom**: Map shows blank or error message
|
||||
|
||||
**Solutions**:
|
||||
1. Check API key in config:
|
||||
```bash
|
||||
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
|
||||
```
|
||||
2. Verify API key in Google Cloud Console
|
||||
3. Check browser console for errors
|
||||
4. Verify Maps JavaScript API is enabled
|
||||
|
||||
### Geolocation Not Working
|
||||
|
||||
**Symptom**: "Use Current Location" button doesn't work
|
||||
|
||||
**Solutions**:
|
||||
1. Check browser permissions (user must allow)
|
||||
2. Requires HTTPS in production (not localhost)
|
||||
3. Some browsers block geolocation in iframes
|
||||
4. Fallback to manual coordinates
|
||||
|
||||
### Stations Not Saving
|
||||
|
||||
**Symptom**: Save button doesn't work or errors
|
||||
|
||||
**Solutions**:
|
||||
1. Verify user is authenticated (JWT present)
|
||||
2. Check station is in cache (search first)
|
||||
3. Review network tab for API errors
|
||||
4. Check backend logs for issues
|
||||
|
||||
### Search Returns No Results
|
||||
|
||||
**Symptom**: Search completes but no stations shown
|
||||
|
||||
**Solutions**:
|
||||
1. Verify location is correct (lat/lng valid)
|
||||
2. Try larger radius (default 5km may be too small)
|
||||
3. Check Google Maps API quota (not exceeded)
|
||||
4. Review backend circuit breaker state
|
||||
|
||||
## References
|
||||
|
||||
- Backend API: `/backend/src/features/stations/docs/API.md`
|
||||
- Backend Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
|
||||
- Testing Guide: `/backend/src/features/stations/docs/TESTING.md`
|
||||
- Google Maps Setup: `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
|
||||
- Runtime Config: `/frontend/docs/RUNTIME-CONFIG.md`
|
||||
- Main README: `/backend/src/features/stations/README.md`
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @ai-summary Tests for stations API client
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { stationsApi } from '../../api/stations.api';
|
||||
import { Station, StationSearchRequest } from '../../types/stations.types';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
const mockStations: Station[] = [
|
||||
{
|
||||
placeId: 'test-1',
|
||||
name: 'Shell Station',
|
||||
address: '123 Main St',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250
|
||||
}
|
||||
];
|
||||
|
||||
describe('stationsApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('searchStations', () => {
|
||||
it('should search for stations with valid request', async () => {
|
||||
const request: StationSearchRequest = {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { stations: mockStations }
|
||||
});
|
||||
|
||||
const result = await stationsApi.searchStations(request);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/stations/search',
|
||||
request
|
||||
);
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
|
||||
it('should handle search without radius', async () => {
|
||||
const request: StationSearchRequest = {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { stations: mockStations }
|
||||
});
|
||||
|
||||
await stationsApi.searchStations(request);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/stations/search',
|
||||
request
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
mockedAxios.post.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(
|
||||
stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('should handle 401 unauthorized', async () => {
|
||||
mockedAxios.post.mockRejectedValue({
|
||||
response: { status: 401, data: { message: 'Unauthorized' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle 500 server error', async () => {
|
||||
mockedAxios.post.mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'Internal server error' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveStation', () => {
|
||||
it('should save a station with metadata', async () => {
|
||||
const placeId = 'test-place-id';
|
||||
const data = {
|
||||
nickname: 'Work Station',
|
||||
notes: 'Best prices',
|
||||
isFavorite: true
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { id: '123', ...data, placeId }
|
||||
});
|
||||
|
||||
const result = await stationsApi.saveStation(placeId, data);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/stations/save', {
|
||||
placeId,
|
||||
...data
|
||||
});
|
||||
expect(result.placeId).toBe(placeId);
|
||||
});
|
||||
|
||||
it('should save station without optional fields', async () => {
|
||||
const placeId = 'test-place-id';
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { id: '123', placeId }
|
||||
});
|
||||
|
||||
await stationsApi.saveStation(placeId);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/stations/save', {
|
||||
placeId
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle save errors', async () => {
|
||||
mockedAxios.post.mockRejectedValue({
|
||||
response: { status: 404, data: { message: 'Station not found' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.saveStation('invalid-id')
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSavedStations', () => {
|
||||
it('should fetch all saved stations', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: mockStations
|
||||
});
|
||||
|
||||
const result = await stationsApi.getSavedStations();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('/api/stations/saved');
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
|
||||
it('should return empty array when no saved stations', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: []
|
||||
});
|
||||
|
||||
const result = await stationsApi.getSavedStations();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
mockedAxios.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(stationsApi.getSavedStations()).rejects.toThrow(
|
||||
'Network error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSavedStation', () => {
|
||||
it('should delete a station by placeId', async () => {
|
||||
const placeId = 'test-place-id';
|
||||
|
||||
mockedAxios.delete.mockResolvedValue({ status: 204 });
|
||||
|
||||
await stationsApi.deleteSavedStation(placeId);
|
||||
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith(
|
||||
`/api/stations/saved/${placeId}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 404 not found', async () => {
|
||||
mockedAxios.delete.mockRejectedValue({
|
||||
response: { status: 404, data: { message: 'Not found' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.deleteSavedStation('invalid-id')
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
mockedAxios.delete.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(
|
||||
stationsApi.deleteSavedStation('test-id')
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Construction', () => {
|
||||
it('should use correct API base path', async () => {
|
||||
mockedAxios.post.mockResolvedValue({ data: { stations: [] } });
|
||||
|
||||
await stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
const callUrl = mockedAxios.post.mock.calls[0][0];
|
||||
expect(callUrl).toContain('/api/stations');
|
||||
});
|
||||
|
||||
it('should construct correct saved station URL', async () => {
|
||||
mockedAxios.delete.mockResolvedValue({ status: 204 });
|
||||
|
||||
await stationsApi.deleteSavedStation('test-place-id');
|
||||
|
||||
const callUrl = mockedAxios.delete.mock.calls[0][0];
|
||||
expect(callUrl).toBe('/api/stations/saved/test-place-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Payload Validation', () => {
|
||||
it('should send correct payload for search', async () => {
|
||||
const request = {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({ data: { stations: [] } });
|
||||
|
||||
await stationsApi.searchStations(request);
|
||||
|
||||
const payload = mockedAxios.post.mock.calls[0][1];
|
||||
expect(payload).toEqual(request);
|
||||
});
|
||||
|
||||
it('should send correct payload for save', async () => {
|
||||
const placeId = 'test-id';
|
||||
const data = { nickname: 'Test', isFavorite: true };
|
||||
|
||||
mockedAxios.post.mockResolvedValue({ data: {} });
|
||||
|
||||
await stationsApi.saveStation(placeId, data);
|
||||
|
||||
const payload = mockedAxios.post.mock.calls[0][1];
|
||||
expect(payload).toEqual({ placeId, ...data });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Parsing', () => {
|
||||
it('should parse search response correctly', async () => {
|
||||
const responseData = {
|
||||
stations: mockStations,
|
||||
searchLocation: { latitude: 37.7749, longitude: -122.4194 },
|
||||
searchRadius: 5000
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({ data: responseData });
|
||||
|
||||
const result = await stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
|
||||
it('should parse saved stations response', async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: mockStations });
|
||||
|
||||
const result = await stationsApi.getSavedStations();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @ai-summary Tests for StationCard component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { StationCard } from '../../components/StationCard';
|
||||
import { Station } from '../../types/stations.types';
|
||||
|
||||
const mockStation: Station = {
|
||||
placeId: 'test-place-id',
|
||||
name: 'Shell Gas Station',
|
||||
address: '123 Main St, San Francisco, CA 94105',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250,
|
||||
photoUrl: 'https://example.com/photo.jpg'
|
||||
};
|
||||
|
||||
describe('StationCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.open = jest.fn();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render station name and address', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
||||
expect(screen.getByText('123 Main St, San Francisco, CA 94105')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render station photo if available', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const photo = screen.getByAltText('Shell Gas Station');
|
||||
expect(photo).toBeInTheDocument();
|
||||
expect(photo).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
it('should render rating when available', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText('4.2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render distance chip', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText(/mi/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not crash when photo is missing', () => {
|
||||
const stationWithoutPhoto = { ...mockStation, photoUrl: undefined };
|
||||
render(<StationCard station={stationWithoutPhoto} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save/Delete Actions', () => {
|
||||
it('should call onSave when bookmark button clicked (not saved)', () => {
|
||||
const onSave = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={false} onSave={onSave} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Add to favorites');
|
||||
fireEvent.click(bookmarkButton);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(mockStation);
|
||||
});
|
||||
|
||||
it('should call onDelete when bookmark button clicked (saved)', () => {
|
||||
const onDelete = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={true} onDelete={onDelete} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Remove from favorites');
|
||||
fireEvent.click(bookmarkButton);
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith(mockStation.placeId);
|
||||
});
|
||||
|
||||
it('should show filled bookmark icon when saved', () => {
|
||||
render(<StationCard station={mockStation} isSaved={true} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Remove from favorites');
|
||||
expect(bookmarkButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show outline bookmark icon when not saved', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Add to favorites');
|
||||
expect(bookmarkButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Directions Link', () => {
|
||||
it('should open Google Maps when directions button clicked', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const directionsButton = screen.getByTitle('Get directions');
|
||||
fireEvent.click(directionsButton);
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining('google.com/maps'),
|
||||
'_blank'
|
||||
);
|
||||
});
|
||||
|
||||
it('should encode address in directions URL', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const directionsButton = screen.getByTitle('Get directions');
|
||||
fireEvent.click(directionsButton);
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining(encodeURIComponent(mockStation.address)),
|
||||
'_blank'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Targets', () => {
|
||||
it('should have minimum 44px button heights', () => {
|
||||
const { container } = render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
buttons.forEach((button) => {
|
||||
const styles = window.getComputedStyle(button);
|
||||
const minHeight = parseInt(styles.minHeight);
|
||||
expect(minHeight).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card Selection', () => {
|
||||
it('should call onSelect when card is clicked', () => {
|
||||
const onSelect = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={false} onSelect={onSelect} />);
|
||||
|
||||
const card = screen.getByText('Shell Gas Station').closest('.MuiCard-root');
|
||||
if (card) {
|
||||
fireEvent.click(card);
|
||||
expect(onSelect).toHaveBeenCalledWith(mockStation);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not call onSelect when button is clicked', () => {
|
||||
const onSelect = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={false} onSelect={onSelect} />);
|
||||
|
||||
const directionsButton = screen.getByTitle('Get directions');
|
||||
fireEvent.click(directionsButton);
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @ai-summary Tests for useStationsSearch hook
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useStationsSearch } from '../../hooks/useStationsSearch';
|
||||
import { stationsApi } from '../../api/stations.api';
|
||||
import { Station } from '../../types/stations.types';
|
||||
|
||||
jest.mock('../../api/stations.api');
|
||||
|
||||
const mockStations: Station[] = [
|
||||
{
|
||||
placeId: 'test-1',
|
||||
name: 'Shell Station',
|
||||
address: '123 Main St',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250
|
||||
},
|
||||
{
|
||||
placeId: 'test-2',
|
||||
name: 'Chevron Station',
|
||||
address: '456 Market St',
|
||||
latitude: 37.7923,
|
||||
longitude: -122.3989,
|
||||
rating: 4.5,
|
||||
distance: 1200
|
||||
}
|
||||
];
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false }
|
||||
}
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }): React.ReactElement =>
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
children
|
||||
);
|
||||
};
|
||||
|
||||
describe('useStationsSearch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Search Execution', () => {
|
||||
it('should search for stations and return results', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockStations);
|
||||
expect(stationsApi.searchStations).toHaveBeenCalledWith({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search with custom radius', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 10000
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(stationsApi.searchStations).toHaveBeenCalledWith({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 10000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show pending state during search', () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(mockStations), 100))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
expect(result.current.isPending).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear pending state after success', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API errors', async () => {
|
||||
const error = new Error('API Error');
|
||||
(stationsApi.searchStations as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call onError callback on failure', async () => {
|
||||
const error = new Error('Network error');
|
||||
(stationsApi.searchStations as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const onError = jest.fn();
|
||||
const { result } = renderHook(() => useStationsSearch({ onError }), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Success Callback', () => {
|
||||
it('should call onSuccess callback with data', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
const { result } = renderHook(() => useStationsSearch({ onSuccess }), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockStations);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
165
frontend/src/features/stations/api/stations.api.ts
Normal file
165
frontend/src/features/stations/api/stations.api.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @ai-summary API client for Gas Stations feature
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/core/api/client';
|
||||
import {
|
||||
Station,
|
||||
StationSearchRequest,
|
||||
StationSearchResponse,
|
||||
SavedStation,
|
||||
SaveStationData,
|
||||
ApiError
|
||||
} from '../types/stations.types';
|
||||
|
||||
const API_BASE = '/api/stations';
|
||||
|
||||
class StationsApiClient {
|
||||
/**
|
||||
* Search for nearby gas stations
|
||||
* @param request Search parameters (latitude, longitude, radius)
|
||||
* @returns Promise with stations found
|
||||
*/
|
||||
async searchStations(request: StationSearchRequest): Promise<Station[]> {
|
||||
try {
|
||||
const response = await apiClient.post<StationSearchResponse>(
|
||||
`${API_BASE}/search`,
|
||||
{
|
||||
latitude: request.latitude,
|
||||
longitude: request.longitude,
|
||||
radius: request.radius || 5000,
|
||||
fuelType: request.fuelType
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.stations || [];
|
||||
} catch (error) {
|
||||
console.error('Station search failed:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a station to user favorites
|
||||
* @param placeId Google Places ID
|
||||
* @param data Station metadata (nickname, notes, isFavorite)
|
||||
* @returns Saved station record
|
||||
*/
|
||||
async saveStation(
|
||||
placeId: string,
|
||||
data: SaveStationData
|
||||
): Promise<SavedStation> {
|
||||
try {
|
||||
const response = await apiClient.post<SavedStation>(
|
||||
`${API_BASE}/save`,
|
||||
{
|
||||
placeId,
|
||||
...data
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Save station failed:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all saved stations for current user
|
||||
* @returns Array of saved stations
|
||||
*/
|
||||
async getSavedStations(): Promise<SavedStation[]> {
|
||||
try {
|
||||
const response = await apiClient.get<SavedStation[]>(
|
||||
`${API_BASE}/saved`
|
||||
);
|
||||
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Get saved stations failed:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific saved station
|
||||
* @param placeId Google Places ID
|
||||
* @returns Saved station details or null
|
||||
*/
|
||||
async getSavedStation(placeId: string): Promise<SavedStation | null> {
|
||||
try {
|
||||
const response = await apiClient.get<SavedStation>(
|
||||
`${API_BASE}/saved/${placeId}`
|
||||
);
|
||||
|
||||
return response.data || null;
|
||||
} catch (error) {
|
||||
if ((error as any)?.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error('Get saved station failed:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved station
|
||||
* @param placeId Google Places ID
|
||||
*/
|
||||
async deleteSavedStation(placeId: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.delete(`${API_BASE}/saved/${placeId}`);
|
||||
} catch (error) {
|
||||
console.error('Delete saved station failed:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a saved station's metadata
|
||||
* @param placeId Google Places ID
|
||||
* @param data Updated metadata
|
||||
*/
|
||||
async updateSavedStation(
|
||||
placeId: string,
|
||||
data: Partial<SaveStationData>
|
||||
): Promise<SavedStation> {
|
||||
try {
|
||||
const response = await apiClient.patch<SavedStation>(
|
||||
`${API_BASE}/saved/${placeId}`,
|
||||
data
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Update saved station failed:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors with proper typing
|
||||
*/
|
||||
private handleError(error: unknown): ApiError {
|
||||
const axiosError = error as any;
|
||||
|
||||
if (axiosError?.response?.data) {
|
||||
return axiosError.response.data as ApiError;
|
||||
}
|
||||
|
||||
if (axiosError?.message) {
|
||||
return {
|
||||
message: axiosError.message,
|
||||
code: 'UNKNOWN_ERROR'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'An unexpected error occurred',
|
||||
code: 'UNKNOWN_ERROR'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const stationsApi = new StationsApiClient();
|
||||
163
frontend/src/features/stations/components/SavedStationsList.tsx
Normal file
163
frontend/src/features/stations/components/SavedStationsList.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @ai-summary List of user's saved/favorited stations
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { SavedStation } from '../types/stations.types';
|
||||
import { formatDistance } from '../utils/distance';
|
||||
|
||||
interface SavedStationsListProps {
|
||||
stations: SavedStation[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onSelectStation?: (station: SavedStation) => void;
|
||||
onDeleteStation?: (placeId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical list of saved stations with delete option
|
||||
*/
|
||||
export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
||||
stations,
|
||||
error = null,
|
||||
onSelectStation,
|
||||
onDeleteStation
|
||||
}) => {
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ padding: 2 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (stations.length === 0) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', padding: 3 }}>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
No saved stations yet. Save stations from search results to access them
|
||||
quickly.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
{stations.map((station, index) => (
|
||||
<React.Fragment key={station.placeId}>
|
||||
<ListItem
|
||||
disablePadding
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => onSelectStation?.(station)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
component="span"
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
{station.nickname || station.name}
|
||||
</Typography>
|
||||
{station.isFavorite && (
|
||||
<Chip
|
||||
label="Favorite"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="filled"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 0.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{station.address}
|
||||
</Typography>
|
||||
{station.notes && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}
|
||||
>
|
||||
{station.notes}
|
||||
</Typography>
|
||||
)}
|
||||
{station.distance !== undefined && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{formatDistance(station.distance)} away
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteStation?.(station.placeId);
|
||||
}}
|
||||
title="Delete saved station"
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{index < stations.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default SavedStationsList;
|
||||
174
frontend/src/features/stations/components/StationCard.tsx
Normal file
174
frontend/src/features/stations/components/StationCard.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @ai-summary Individual station card component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Box,
|
||||
Rating
|
||||
} from '@mui/material';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||
import DirectionsIcon from '@mui/icons-material/Directions';
|
||||
import { Station } from '../types/stations.types';
|
||||
import { formatDistance } from '../utils/distance';
|
||||
|
||||
interface StationCardProps {
|
||||
station: Station;
|
||||
isSaved: boolean;
|
||||
onSave?: (station: Station) => void;
|
||||
onDelete?: (placeId: string) => void;
|
||||
onSelect?: (station: Station) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Station card showing station details with save/delete buttons
|
||||
* Responsive design: min 44px touch targets on mobile
|
||||
*/
|
||||
export const StationCard: React.FC<StationCardProps> = ({
|
||||
station,
|
||||
isSaved,
|
||||
onSave,
|
||||
onDelete,
|
||||
onSelect
|
||||
}) => {
|
||||
const handleSaveClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isSaved) {
|
||||
onDelete?.(station.placeId);
|
||||
} else {
|
||||
onSave?.(station);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirections = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const mapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(station.address)}`;
|
||||
window.open(mapsUrl, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={() => onSelect?.(station)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 3
|
||||
},
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{station.photoUrl && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={station.photoUrl}
|
||||
alt={station.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
{/* Station Name */}
|
||||
<Typography variant="h6" component="div" noWrap>
|
||||
{station.name}
|
||||
</Typography>
|
||||
|
||||
{/* Address */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
sx={{
|
||||
marginTop: 0.5,
|
||||
marginBottom: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}
|
||||
>
|
||||
{station.address}
|
||||
</Typography>
|
||||
|
||||
{/* Rating and Distance */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
{station.rating > 0 && (
|
||||
<>
|
||||
<Rating
|
||||
value={station.rating / 5}
|
||||
precision={0.1}
|
||||
readOnly
|
||||
size="small"
|
||||
/>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{station.rating.toFixed(1)}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Distance */}
|
||||
{station.distance !== undefined && (
|
||||
<Chip
|
||||
label={formatDistance(station.distance)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ marginBottom: 1 }}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Actions */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: 1,
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
minHeight: '44px',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="large"
|
||||
onClick={handleDirections}
|
||||
title="Get directions"
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
padding: 1
|
||||
}}
|
||||
>
|
||||
<DirectionsIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="large"
|
||||
onClick={handleSaveClick}
|
||||
title={isSaved ? 'Remove from favorites' : 'Add to favorites'}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
padding: 1,
|
||||
color: isSaved ? 'warning.main' : 'inherit'
|
||||
}}
|
||||
>
|
||||
{isSaved ? <BookmarkIcon /> : <BookmarkBorderIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationCard;
|
||||
186
frontend/src/features/stations/components/StationMap.tsx
Normal file
186
frontend/src/features/stations/components/StationMap.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @ai-summary Google Maps component for station visualization
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, CircularProgress, Alert } from '@mui/material';
|
||||
import { Station } from '../types/stations.types';
|
||||
import { loadGoogleMaps, getGoogleMapsApi } from '../utils/maps-loader';
|
||||
import {
|
||||
createStationMarker,
|
||||
createCurrentLocationMarker,
|
||||
createInfoWindow,
|
||||
fitBoundsToMarkers
|
||||
} from '../utils/map-utils';
|
||||
|
||||
interface StationMapProps {
|
||||
stations: Station[];
|
||||
savedPlaceIds?: Set<string>;
|
||||
center?: { lat: number; lng: number };
|
||||
currentLocation?: { latitude: number; longitude: number };
|
||||
zoom?: number;
|
||||
onMarkerClick?: (station: Station) => void;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps component showing station markers
|
||||
* Responsive height: 300px mobile, 500px desktop
|
||||
* Blue markers for normal stations, gold for saved
|
||||
*/
|
||||
export const StationMap: React.FC<StationMapProps> = ({
|
||||
stations,
|
||||
savedPlaceIds = new Set(),
|
||||
center,
|
||||
currentLocation,
|
||||
zoom = 12,
|
||||
onMarkerClick,
|
||||
height = '500px'
|
||||
}) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<google.maps.Map | null>(null);
|
||||
const markers = useRef<google.maps.Marker[]>([]);
|
||||
const infoWindows = useRef<google.maps.InfoWindow[]>([]);
|
||||
const currentLocationMarker = useRef<google.maps.Marker | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
const initMap = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Load Google Maps API
|
||||
await loadGoogleMaps();
|
||||
const maps = getGoogleMapsApi();
|
||||
|
||||
if (!mapContainer.current) return;
|
||||
|
||||
// Create map
|
||||
const defaultCenter = center || {
|
||||
lat: currentLocation?.latitude || 37.7749,
|
||||
lng: currentLocation?.longitude || -122.4194
|
||||
};
|
||||
|
||||
map.current = new maps.Map(mapContainer.current, {
|
||||
zoom,
|
||||
center: defaultCenter,
|
||||
mapTypeControl: true,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: true
|
||||
});
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load map');
|
||||
console.error('Map initialization failed:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initMap();
|
||||
}, []);
|
||||
|
||||
// Update markers when stations or saved status changes
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
|
||||
// Clear old markers and info windows
|
||||
markers.current.forEach((marker) => marker.setMap(null));
|
||||
infoWindows.current.forEach((iw) => iw.close());
|
||||
markers.current = [];
|
||||
infoWindows.current = [];
|
||||
|
||||
getGoogleMapsApi();
|
||||
let allMarkers: google.maps.Marker[] = [];
|
||||
|
||||
// Add station markers
|
||||
stations.forEach((station) => {
|
||||
const isSaved = savedPlaceIds.has(station.placeId);
|
||||
const marker = createStationMarker(station, map.current!, isSaved);
|
||||
const infoWindow = createInfoWindow(station, isSaved);
|
||||
|
||||
markers.current.push(marker);
|
||||
infoWindows.current.push(infoWindow);
|
||||
allMarkers.push(marker);
|
||||
|
||||
// Add click listener
|
||||
marker.addListener('click', () => {
|
||||
// Close all other info windows
|
||||
infoWindows.current.forEach((iw) => iw.close());
|
||||
|
||||
// Open this one
|
||||
infoWindow.open(map.current, marker);
|
||||
onMarkerClick?.(station);
|
||||
});
|
||||
});
|
||||
|
||||
// Add current location marker
|
||||
if (currentLocation) {
|
||||
if (currentLocationMarker.current) {
|
||||
currentLocationMarker.current.setMap(null);
|
||||
}
|
||||
|
||||
currentLocationMarker.current = createCurrentLocationMarker(
|
||||
currentLocation.latitude,
|
||||
currentLocation.longitude,
|
||||
map.current
|
||||
);
|
||||
allMarkers.push(currentLocationMarker.current);
|
||||
}
|
||||
|
||||
// Fit bounds to show all markers
|
||||
if (allMarkers.length > 0) {
|
||||
fitBoundsToMarkers(map.current, allMarkers);
|
||||
}
|
||||
}, [stations, savedPlaceIds, currentLocation, onMarkerClick]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 2
|
||||
}}
|
||||
>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mapContainer}
|
||||
sx={{
|
||||
height,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e0e0e0'
|
||||
}}
|
||||
>
|
||||
{isLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationMap;
|
||||
105
frontend/src/features/stations/components/StationsList.tsx
Normal file
105
frontend/src/features/stations/components/StationsList.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @ai-summary Grid list of stations from search results
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Box,
|
||||
Typography,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { Station } from '../types/stations.types';
|
||||
import StationCard from './StationCard';
|
||||
|
||||
interface StationsListProps {
|
||||
stations: Station[];
|
||||
savedPlaceIds?: Set<string>;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onSaveStation?: (station: Station) => void;
|
||||
onDeleteStation?: (placeId: string) => void;
|
||||
onSelectStation?: (station: Station) => void;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive grid of station cards
|
||||
* Layout: 1 col mobile, 2 cols tablet, 3 cols desktop
|
||||
*/
|
||||
export const StationsList: React.FC<StationsListProps> = ({
|
||||
stations,
|
||||
savedPlaceIds = new Set(),
|
||||
loading = false,
|
||||
error = null,
|
||||
onSaveStation,
|
||||
onDeleteStation,
|
||||
onSelectStation,
|
||||
onRetry
|
||||
}) => {
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={i}>
|
||||
<Skeleton variant="rectangular" height={300} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ padding: 2 }}>
|
||||
<Alert severity="error">
|
||||
<Typography variant="subtitle2">{error}</Typography>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={onRetry}
|
||||
sx={{ marginTop: 1 }}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (stations.length === 0) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', padding: 3 }}>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
No stations found. Try adjusting your search location or radius.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Stations grid
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{stations.map((station) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={station.placeId}>
|
||||
<StationCard
|
||||
station={station}
|
||||
isSaved={savedPlaceIds.has(station.placeId)}
|
||||
onSave={onSaveStation}
|
||||
onDelete={onDeleteStation}
|
||||
onSelect={onSelectStation}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationsList;
|
||||
207
frontend/src/features/stations/components/StationsSearchForm.tsx
Normal file
207
frontend/src/features/stations/components/StationsSearchForm.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @ai-summary Form for searching nearby gas stations
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Slider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
InputAdornment
|
||||
} 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';
|
||||
|
||||
interface StationsSearchFormProps {
|
||||
onSearch: (request: StationSearchRequest) => void;
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search form with manual location input and geolocation button
|
||||
* Radius slider: 1-25 miles, default 5 miles
|
||||
*/
|
||||
export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
onSearch,
|
||||
isSearching = false
|
||||
}) => {
|
||||
const [latitude, setLatitude] = useState<number | ''>('');
|
||||
const [longitude, setLongitude] = useState<number | ''>('');
|
||||
const [radius, setRadius] = useState(5); // Miles
|
||||
const [locationError, setLocationError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
isPending: isGeolocating,
|
||||
error: geoError,
|
||||
requestPermission,
|
||||
clearError: clearGeoError
|
||||
} = useGeolocation();
|
||||
|
||||
// Update form when geolocation succeeds
|
||||
useEffect(() => {
|
||||
if (coordinates) {
|
||||
setLatitude(coordinates.latitude);
|
||||
setLongitude(coordinates.longitude);
|
||||
setLocationError(null);
|
||||
}
|
||||
}, [coordinates]);
|
||||
|
||||
// Handle geolocation errors
|
||||
useEffect(() => {
|
||||
if (geoError) {
|
||||
if (geoError === GeolocationError.PERMISSION_DENIED) {
|
||||
setLocationError('Location permission denied. Please enable it in browser settings.');
|
||||
} else if (geoError === GeolocationError.TIMEOUT) {
|
||||
setLocationError('Location request timed out. Try again.');
|
||||
} else if (geoError === GeolocationError.POSITION_UNAVAILABLE) {
|
||||
setLocationError('Location not available. Try a different device.');
|
||||
} else {
|
||||
setLocationError('Unable to get location. Please enter manually.');
|
||||
}
|
||||
}
|
||||
}, [geoError]);
|
||||
|
||||
const handleUseCurrentLocation = () => {
|
||||
clearGeoError();
|
||||
requestPermission();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (latitude === '' || longitude === '') {
|
||||
setLocationError('Please enter coordinates or use current location');
|
||||
return;
|
||||
}
|
||||
|
||||
const request: StationSearchRequest = {
|
||||
latitude: typeof latitude === 'number' ? latitude : 0,
|
||||
longitude: typeof longitude === 'number' ? longitude : 0,
|
||||
radius: radius * 1609.34 // Convert miles to meters
|
||||
};
|
||||
|
||||
onSearch(request);
|
||||
};
|
||||
|
||||
const handleRadiusChange = (
|
||||
_event: Event,
|
||||
newValue: number | number[]
|
||||
) => {
|
||||
if (typeof newValue === 'number') {
|
||||
setRadius(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}}
|
||||
sx={{ padding: 2, display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
{/* Geolocation Button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={isGeolocating ? <CircularProgress size={20} /> : <MyLocationIcon />}
|
||||
onClick={handleUseCurrentLocation}
|
||||
disabled={isGeolocating}
|
||||
fullWidth
|
||||
>
|
||||
{isGeolocating ? 'Getting location...' : 'Use Current Location'}
|
||||
</Button>
|
||||
|
||||
{/* Or Divider */}
|
||||
<Box sx={{ textAlign: 'center', color: 'textSecondary' }}>or</Box>
|
||||
|
||||
{/* Manual Latitude Input */}
|
||||
<TextField
|
||||
label="Latitude"
|
||||
type="number"
|
||||
value={latitude}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setLatitude(val === '' ? '' : parseFloat(val));
|
||||
}}
|
||||
placeholder="37.7749"
|
||||
inputProps={{ step: '0.0001', min: '-90', max: '90' }}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<LocationIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Manual Longitude Input */}
|
||||
<TextField
|
||||
label="Longitude"
|
||||
type="number"
|
||||
value={longitude}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setLongitude(val === '' ? '' : parseFloat(val));
|
||||
}}
|
||||
placeholder="-122.4194"
|
||||
inputProps={{ step: '0.0001', min: '-180', max: '180' }}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<LocationIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Radius Slider */}
|
||||
<FormControl fullWidth>
|
||||
<FormLabel>Search Radius: {radius} mi</FormLabel>
|
||||
<Slider
|
||||
value={radius}
|
||||
onChange={handleRadiusChange}
|
||||
min={1}
|
||||
max={25}
|
||||
step={0.5}
|
||||
marks={[
|
||||
{ value: 1, label: '1 mi' },
|
||||
{ value: 5, label: '5 mi' },
|
||||
{ value: 10, label: '10 mi' },
|
||||
{ value: 25, label: '25 mi' }
|
||||
]}
|
||||
sx={{ marginTop: 2, marginBottom: 1 }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Error Messages */}
|
||||
{locationError && (
|
||||
<Alert severity="error">{locationError}</Alert>
|
||||
)}
|
||||
|
||||
{/* Search Button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || latitude === '' || longitude === ''}
|
||||
sx={{
|
||||
minHeight: '44px',
|
||||
marginTop: 1
|
||||
}}
|
||||
>
|
||||
{isSearching ? <CircularProgress size={24} /> : 'Search Stations'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationsSearchForm;
|
||||
9
frontend/src/features/stations/components/index.ts
Normal file
9
frontend/src/features/stations/components/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @ai-summary Stations feature components exports
|
||||
*/
|
||||
|
||||
export { StationCard } from './StationCard';
|
||||
export { StationsList } from './StationsList';
|
||||
export { SavedStationsList } from './SavedStationsList';
|
||||
export { StationsSearchForm } from './StationsSearchForm';
|
||||
export { StationMap } from './StationMap';
|
||||
9
frontend/src/features/stations/hooks/index.ts
Normal file
9
frontend/src/features/stations/hooks/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @ai-summary Stations feature hooks exports
|
||||
*/
|
||||
|
||||
export { useStationsSearch } from './useStationsSearch';
|
||||
export { useSavedStations, useInvalidateSavedStations, useUpdateSavedStationsCache } from './useSavedStations';
|
||||
export { useSaveStation } from './useSaveStation';
|
||||
export { useDeleteStation } from './useDeleteStation';
|
||||
export { useGeolocation } from './useGeolocation';
|
||||
61
frontend/src/features/stations/hooks/useDeleteStation.ts
Normal file
61
frontend/src/features/stations/hooks/useDeleteStation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @ai-summary Hook for deleting saved stations
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { stationsApi } from '../api/stations.api';
|
||||
import { SavedStation, ApiError } from '../types/stations.types';
|
||||
import { useUpdateSavedStationsCache } from './useSavedStations';
|
||||
|
||||
interface UseDeleteStationOptions {
|
||||
onSuccess?: (placeId: string) => void;
|
||||
onError?: (error: ApiError) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for deleting a saved station
|
||||
* Includes optimistic removal from cache
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { mutate: deleteStation, isPending } = useDeleteStation();
|
||||
*
|
||||
* const handleDelete = (placeId: string) => {
|
||||
* deleteStation(placeId);
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function useDeleteStation(options?: UseDeleteStationOptions) {
|
||||
const updateCache = useUpdateSavedStationsCache();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (placeId: string) => {
|
||||
await stationsApi.deleteSavedStation(placeId);
|
||||
return placeId;
|
||||
},
|
||||
onMutate: async (placeId) => {
|
||||
// Save previous state for rollback
|
||||
let previousStations: SavedStation[] | undefined;
|
||||
|
||||
// Optimistic update: remove station immediately
|
||||
updateCache((old) => {
|
||||
previousStations = old;
|
||||
if (!old) return [];
|
||||
return old.filter((s) => s.placeId !== placeId);
|
||||
});
|
||||
|
||||
return { previousStations, placeId };
|
||||
},
|
||||
onSuccess: (placeId) => {
|
||||
options?.onSuccess?.(placeId);
|
||||
},
|
||||
onError: (error, _placeId, context) => {
|
||||
// Rollback optimistic update on error
|
||||
if (context?.previousStations) {
|
||||
updateCache(() => context.previousStations || []);
|
||||
}
|
||||
|
||||
options?.onError?.(error as ApiError);
|
||||
}
|
||||
});
|
||||
}
|
||||
168
frontend/src/features/stations/hooks/useGeolocation.ts
Normal file
168
frontend/src/features/stations/hooks/useGeolocation.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @ai-summary Hook for browser geolocation API
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { GeolocationCoordinates, GeolocationError } from '../types/stations.types';
|
||||
|
||||
interface UseGeolocationState {
|
||||
/** Current coordinates or null if not available */
|
||||
coordinates: GeolocationCoordinates | null;
|
||||
/** Whether location request is in progress */
|
||||
isPending: boolean;
|
||||
/** Error if geolocation failed */
|
||||
error: GeolocationError | null;
|
||||
/** Whether user has granted permission */
|
||||
hasPermission: boolean;
|
||||
}
|
||||
|
||||
interface UseGeolocationOptions {
|
||||
/** Enable high accuracy (slower but more precise) */
|
||||
enableHighAccuracy?: boolean;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Maximum cache age in milliseconds */
|
||||
maximumAge?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing browser geolocation API
|
||||
* Handles permissions, errors, and provides methods to request location
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { coordinates, isPending, error, requestLocation } = useGeolocation();
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {coordinates && (
|
||||
* <p>Location: {coordinates.latitude}, {coordinates.longitude}</p>
|
||||
* )}
|
||||
* <button onClick={requestLocation} disabled={isPending}>
|
||||
* Get Current Location
|
||||
* </button>
|
||||
* {error && <p>Error: {error}</p>}
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useGeolocation(options?: UseGeolocationOptions) {
|
||||
const [state, setState] = useState<UseGeolocationState>({
|
||||
coordinates: null,
|
||||
isPending: false,
|
||||
error: null,
|
||||
hasPermission: true
|
||||
});
|
||||
|
||||
// Request user's current location
|
||||
const requestLocation = useCallback(() => {
|
||||
if (!navigator?.geolocation) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: GeolocationError.POSITION_UNAVAILABLE
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isPending: true, error: null }));
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude, accuracy } = position.coords;
|
||||
setState({
|
||||
coordinates: { latitude, longitude, accuracy },
|
||||
isPending: false,
|
||||
error: null,
|
||||
hasPermission: true
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
let geolocationError = GeolocationError.UNKNOWN;
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
geolocationError = GeolocationError.PERMISSION_DENIED;
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
geolocationError = GeolocationError.POSITION_UNAVAILABLE;
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
geolocationError = GeolocationError.TIMEOUT;
|
||||
break;
|
||||
}
|
||||
|
||||
setState({
|
||||
coordinates: null,
|
||||
isPending: false,
|
||||
error: geolocationError,
|
||||
hasPermission:
|
||||
geolocationError !== GeolocationError.PERMISSION_DENIED
|
||||
});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: options?.enableHighAccuracy ?? false,
|
||||
timeout: options?.timeout ?? 10000,
|
||||
maximumAge: options?.maximumAge ?? 0
|
||||
}
|
||||
);
|
||||
}, [options]);
|
||||
|
||||
// Request permission explicitly (iOS 13+)
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (!navigator?.permissions?.query) {
|
||||
// Permissions API not supported, fallback to direct request
|
||||
requestLocation();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await navigator.permissions.query({
|
||||
name: 'geolocation'
|
||||
});
|
||||
|
||||
if (permission.state === 'granted') {
|
||||
setState((prev) => ({ ...prev, hasPermission: true }));
|
||||
requestLocation();
|
||||
} else if (permission.state === 'prompt') {
|
||||
requestLocation();
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: GeolocationError.PERMISSION_DENIED,
|
||||
hasPermission: false
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback: just request location
|
||||
requestLocation();
|
||||
}
|
||||
}, [requestLocation]);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Clear coordinates
|
||||
const clearLocation = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, coordinates: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
requestLocation,
|
||||
requestPermission,
|
||||
clearError,
|
||||
clearLocation,
|
||||
/**
|
||||
* Convenience method to get coordinates object
|
||||
* Throws error if location not available
|
||||
*/
|
||||
getCoordinates: () => {
|
||||
if (!state.coordinates) {
|
||||
throw new Error('Location coordinates not available');
|
||||
}
|
||||
return state.coordinates;
|
||||
}
|
||||
};
|
||||
}
|
||||
100
frontend/src/features/stations/hooks/useSaveStation.ts
Normal file
100
frontend/src/features/stations/hooks/useSaveStation.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @ai-summary Hook for saving stations to favorites
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { stationsApi } from '../api/stations.api';
|
||||
import { SavedStation, SaveStationData, ApiError } from '../types/stations.types';
|
||||
import { useSavedStationsQueryKey, useUpdateSavedStationsCache } from './useSavedStations';
|
||||
|
||||
interface UseSaveStationOptions {
|
||||
onSuccess?: (station: SavedStation) => void;
|
||||
onError?: (error: ApiError) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for saving a station to favorites
|
||||
* Includes optimistic updates to saved stations cache
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { mutate: saveStation, isPending } = useSaveStation();
|
||||
*
|
||||
* const handleSave = (placeId: string) => {
|
||||
* saveStation({
|
||||
* placeId,
|
||||
* nickname: 'My Station',
|
||||
* isFavorite: true
|
||||
* });
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function useSaveStation(options?: UseSaveStationOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const savedStationsKey = useSavedStationsQueryKey();
|
||||
const updateCache = useUpdateSavedStationsCache();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
placeId,
|
||||
data
|
||||
}: {
|
||||
placeId: string;
|
||||
data: SaveStationData;
|
||||
}) => {
|
||||
return stationsApi.saveStation(placeId, data);
|
||||
},
|
||||
onMutate: async ({ placeId, data }) => {
|
||||
// Optimistic update: add station to cache immediately
|
||||
updateCache((old) => {
|
||||
if (!old) return [];
|
||||
|
||||
const exists = old.some((s) => s.placeId === placeId);
|
||||
if (exists) return old;
|
||||
|
||||
// Create optimistic station entry
|
||||
const optimisticStation: SavedStation = {
|
||||
id: `temp-${placeId}`,
|
||||
placeId,
|
||||
name: data.nickname || 'New Station',
|
||||
address: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
rating: 0,
|
||||
userId: '', // Will be filled by server
|
||||
nickname: data.nickname,
|
||||
notes: data.notes,
|
||||
isFavorite: data.isFavorite ?? false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
return [...old, optimisticStation];
|
||||
});
|
||||
|
||||
// Return context for rollback
|
||||
return { placeId };
|
||||
},
|
||||
onSuccess: (station) => {
|
||||
// Invalidate query to fetch fresh data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: savedStationsKey
|
||||
});
|
||||
|
||||
options?.onSuccess?.(station);
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
// Rollback optimistic update on error
|
||||
if (context?.placeId) {
|
||||
updateCache((old) => {
|
||||
if (!old) return [];
|
||||
return old.filter(
|
||||
(s) => s.placeId !== context.placeId || !s.id.startsWith('temp-')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
options?.onError?.(error as ApiError);
|
||||
}
|
||||
});
|
||||
}
|
||||
76
frontend/src/features/stations/hooks/useSavedStations.ts
Normal file
76
frontend/src/features/stations/hooks/useSavedStations.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @ai-summary Hook for managing saved stations with caching
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { stationsApi } from '../api/stations.api';
|
||||
import { SavedStation } from '../types/stations.types';
|
||||
|
||||
const SAVED_STATIONS_QUERY_KEY = ['stations', 'saved'];
|
||||
|
||||
interface UseSavedStationsOptions {
|
||||
/** Auto-refetch when window regains focus */
|
||||
refetchOnWindowFocus?: boolean;
|
||||
/** Cache time in milliseconds (default: 5 minutes) */
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query hook for user's saved stations
|
||||
* Caches results and auto-refetches on window focus
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: savedStations, isPending, error } = useSavedStations();
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {savedStations?.map(station => (
|
||||
* <StationCard key={station.placeId} station={station} />
|
||||
* ))}
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useSavedStations(options?: UseSavedStationsOptions) {
|
||||
return useQuery({
|
||||
queryKey: SAVED_STATIONS_QUERY_KEY,
|
||||
queryFn: () => stationsApi.getSavedStations(),
|
||||
staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes default
|
||||
refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true,
|
||||
refetchOnMount: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get query client utility for manual cache management
|
||||
*/
|
||||
export function useSavedStationsQueryKey() {
|
||||
return SAVED_STATIONS_QUERY_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate saved stations cache
|
||||
* Call after mutations that affect saved stations
|
||||
*/
|
||||
export function useInvalidateSavedStations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: SAVED_STATIONS_QUERY_KEY
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update saved stations cache with optimistic update
|
||||
* @param updater Function to update the cache data
|
||||
*/
|
||||
export function useUpdateSavedStationsCache() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (updater: (old: SavedStation[] | undefined) => SavedStation[]) => {
|
||||
queryClient.setQueryData(SAVED_STATIONS_QUERY_KEY, updater);
|
||||
};
|
||||
}
|
||||
44
frontend/src/features/stations/hooks/useStationsSearch.ts
Normal file
44
frontend/src/features/stations/hooks/useStationsSearch.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @ai-summary Hook for searching nearby gas stations
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { stationsApi } from '../api/stations.api';
|
||||
import { Station, StationSearchRequest, ApiError } from '../types/stations.types';
|
||||
|
||||
interface UseStationsSearchOptions {
|
||||
onSuccess?: (stations: Station[]) => void;
|
||||
onError?: (error: ApiError) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for searching nearby stations
|
||||
* Not cached by default - each search is independent
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { mutate: search, isPending, data } = useStationsSearch();
|
||||
*
|
||||
* const handleSearch = async () => {
|
||||
* search({
|
||||
* latitude: 37.7749,
|
||||
* longitude: -122.4194,
|
||||
* radius: 5000
|
||||
* });
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function useStationsSearch(options?: UseStationsSearchOptions) {
|
||||
return useMutation({
|
||||
mutationFn: async (request: StationSearchRequest) => {
|
||||
const stations = await stationsApi.searchStations(request);
|
||||
return stations;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
options?.onSuccess?.(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
options?.onError?.(error as ApiError);
|
||||
}
|
||||
});
|
||||
}
|
||||
385
frontend/src/features/stations/mobile/StationsMobileScreen.tsx
Normal file
385
frontend/src/features/stations/mobile/StationsMobileScreen.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @ai-summary Mobile-optimized gas stations screen with bottom tab navigation
|
||||
* @ai-context Three tabs: Search, Saved, Map with responsive mobile-first design
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
BottomNavigation as MuiBottomNavigation,
|
||||
BottomNavigationAction,
|
||||
SwipeableDrawer,
|
||||
Fab,
|
||||
IconButton,
|
||||
Typography,
|
||||
Divider,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import MapIcon from '@mui/icons-material/Map';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { StationsSearchForm } from '../components/StationsSearchForm';
|
||||
import { StationsList } from '../components/StationsList';
|
||||
import { SavedStationsList } from '../components/SavedStationsList';
|
||||
import { StationMap } from '../components/StationMap';
|
||||
|
||||
import {
|
||||
useStationsSearch,
|
||||
useSavedStations,
|
||||
useSaveStation,
|
||||
useDeleteStation,
|
||||
useGeolocation
|
||||
} from '../hooks';
|
||||
|
||||
import {
|
||||
Station,
|
||||
SavedStation,
|
||||
StationSearchRequest
|
||||
} from '../types/stations.types';
|
||||
|
||||
// Tab indices
|
||||
const TAB_SEARCH = 0;
|
||||
const TAB_SAVED = 1;
|
||||
const TAB_MAP = 2;
|
||||
|
||||
// iOS swipeable drawer configuration
|
||||
const iOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
export const StationsMobileScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState(TAB_SEARCH);
|
||||
|
||||
// Bottom sheet state
|
||||
const [selectedStation, setSelectedStation] = useState<Station | SavedStation | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Hooks
|
||||
const { coordinates } = useGeolocation();
|
||||
const {
|
||||
mutate: performSearch,
|
||||
data: searchResults,
|
||||
isPending: isSearching,
|
||||
error: searchError
|
||||
} = useStationsSearch();
|
||||
|
||||
const {
|
||||
data: savedStations,
|
||||
isLoading: isLoadingSaved,
|
||||
error: savedError
|
||||
} = useSavedStations();
|
||||
|
||||
const { mutateAsync: saveStation } = useSaveStation();
|
||||
const { mutateAsync: deleteStation } = useDeleteStation();
|
||||
|
||||
// Compute set of saved place IDs for quick lookup
|
||||
const savedPlaceIds = useMemo(() => {
|
||||
return new Set(savedStations?.map(s => s.placeId) || []);
|
||||
}, [savedStations]);
|
||||
|
||||
// Handle search submission
|
||||
const handleSearch = useCallback((request: StationSearchRequest) => {
|
||||
performSearch(request);
|
||||
}, [performSearch]);
|
||||
|
||||
// Handle station selection (opens bottom sheet)
|
||||
const handleSelectStation = useCallback((station: Station | SavedStation) => {
|
||||
setSelectedStation(station);
|
||||
setDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle save station
|
||||
const handleSaveStation = useCallback(async (station: Station) => {
|
||||
try {
|
||||
await saveStation({
|
||||
placeId: station.placeId,
|
||||
data: {
|
||||
nickname: station.name,
|
||||
isFavorite: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save station:', error);
|
||||
}
|
||||
}, [saveStation]);
|
||||
|
||||
// Handle delete station
|
||||
const handleDeleteStation = useCallback(async (placeId: string) => {
|
||||
try {
|
||||
await deleteStation(placeId);
|
||||
|
||||
// Close drawer if currently viewing deleted station
|
||||
if (selectedStation?.placeId === placeId) {
|
||||
setDrawerOpen(false);
|
||||
setSelectedStation(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete station:', error);
|
||||
}
|
||||
}, [deleteStation, selectedStation]);
|
||||
|
||||
// Close bottom sheet
|
||||
const handleCloseDrawer = useCallback(() => {
|
||||
setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
// Navigate to search tab (from Map FAB)
|
||||
const handleBackToSearch = useCallback(() => {
|
||||
setActiveTab(TAB_SEARCH);
|
||||
}, []);
|
||||
|
||||
// Tab change handler
|
||||
const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
}, []);
|
||||
|
||||
// Pull-to-refresh handler (Search tab) - not implemented yet
|
||||
const handleRefresh = useCallback(() => {
|
||||
// TODO: Implement pull-to-refresh
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
pb: 7, // Space for bottom navigation
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Tab content area */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch'
|
||||
}}
|
||||
>
|
||||
{/* Search Tab */}
|
||||
{activeTab === TAB_SEARCH && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<StationsSearchForm
|
||||
onSearch={handleSearch}
|
||||
isSearching={isSearching}
|
||||
/>
|
||||
|
||||
{searchResults && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<StationsList
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
loading={isSearching}
|
||||
error={searchError ? 'Failed to search stations' : null}
|
||||
onSaveStation={handleSaveStation}
|
||||
onDeleteStation={handleDeleteStation}
|
||||
onSelectStation={handleSelectStation}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Saved Tab */}
|
||||
{activeTab === TAB_SAVED && (
|
||||
<Box sx={{ height: '100%' }}>
|
||||
<SavedStationsList
|
||||
stations={savedStations || []}
|
||||
loading={isLoadingSaved}
|
||||
error={savedError ? 'Failed to load saved stations' : null}
|
||||
onSelectStation={handleSelectStation}
|
||||
onDeleteStation={handleDeleteStation}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Map Tab */}
|
||||
{activeTab === TAB_MAP && (
|
||||
<Box sx={{ height: '100%', position: 'relative' }}>
|
||||
<StationMap
|
||||
stations={searchResults || []}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
currentLocation={coordinates ? {
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude
|
||||
} : undefined}
|
||||
onMarkerClick={handleSelectStation}
|
||||
height="100%"
|
||||
/>
|
||||
|
||||
{/* FAB to go back to search */}
|
||||
<Fab
|
||||
color="primary"
|
||||
size="medium"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
onClick={handleBackToSearch}
|
||||
aria-label="Back to search"
|
||||
>
|
||||
<SearchIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<MuiBottomNavigation
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
showLabels
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
height: 56,
|
||||
zIndex: theme.zIndex.appBar
|
||||
}}
|
||||
>
|
||||
<BottomNavigationAction
|
||||
label="Search"
|
||||
icon={<SearchIcon />}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Saved"
|
||||
icon={<BookmarkIcon />}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Map"
|
||||
icon={<MapIcon />}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
/>
|
||||
</MuiBottomNavigation>
|
||||
|
||||
{/* Bottom Sheet for Station Details */}
|
||||
<SwipeableDrawer
|
||||
anchor="bottom"
|
||||
open={drawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
onOpen={() => setDrawerOpen(true)}
|
||||
disableBackdropTransition={!iOS}
|
||||
disableDiscovery={iOS}
|
||||
sx={{
|
||||
'& .MuiDrawer-paper': {
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'visible'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedStation && (
|
||||
<Box sx={{ p: 2, pb: 4 }}>
|
||||
{/* Drawer handle */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: theme.palette.divider,
|
||||
borderRadius: 2,
|
||||
margin: '0 auto 16px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{'nickname' in selectedStation && selectedStation.nickname
|
||||
? selectedStation.nickname
|
||||
: selectedStation.name}
|
||||
</Typography>
|
||||
{'nickname' in selectedStation && selectedStation.nickname && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedStation.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={handleCloseDrawer}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* Station Details */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Address
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedStation.address}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{selectedStation.rating > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Rating
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedStation.rating.toFixed(1)} / 5.0
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedStation.distance !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Distance
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{(selectedStation.distance / 1609.34).toFixed(1)} miles away
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{'notes' in selectedStation && selectedStation.notes && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Notes
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedStation.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</SwipeableDrawer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationsMobileScreen;
|
||||
251
frontend/src/features/stations/pages/StationsPage.tsx
Normal file
251
frontend/src/features/stations/pages/StationsPage.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @ai-summary Desktop stations page with map and list layout
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Paper,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
Alert,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { Station, StationSearchRequest } from '../types/stations.types';
|
||||
import {
|
||||
useStationsSearch,
|
||||
useSavedStations,
|
||||
useSaveStation,
|
||||
useDeleteStation
|
||||
} from '../hooks';
|
||||
import {
|
||||
StationMap,
|
||||
StationsList,
|
||||
SavedStationsList,
|
||||
StationsSearchForm
|
||||
} from '../components';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`stations-tabpanel-${index}`}
|
||||
aria-labelledby={`stations-tab-${index}`}
|
||||
>
|
||||
{value === index && <Box sx={{ padding: 2 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Desktop stations page layout
|
||||
* Left: Map (60%), Right: Search form + Tabs (40%)
|
||||
* Mobile: Stacks vertically
|
||||
*/
|
||||
export const StationsPage: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [searchResults, setSearchResults] = useState<Station[]>([]);
|
||||
const [mapCenter, setMapCenter] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [currentLocation, setCurrentLocation] = useState<
|
||||
{ latitude: number; longitude: number } | undefined
|
||||
>();
|
||||
|
||||
// Queries and mutations
|
||||
const { mutate: search, isPending: isSearching, error: searchError } = useStationsSearch();
|
||||
const { data: savedStations = [] } = useSavedStations();
|
||||
const { mutate: saveStation } = useSaveStation();
|
||||
const { mutate: deleteStation } = useDeleteStation();
|
||||
|
||||
// Create set of saved place IDs for quick lookup
|
||||
const savedPlaceIds = useMemo(
|
||||
() => new Set(savedStations.map((s) => s.placeId)),
|
||||
[savedStations]
|
||||
);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (request: StationSearchRequest) => {
|
||||
setCurrentLocation({ latitude: request.latitude, longitude: request.longitude });
|
||||
setMapCenter({ lat: request.latitude, lng: request.longitude });
|
||||
|
||||
search(request, {
|
||||
onSuccess: (stations) => {
|
||||
setSearchResults(stations);
|
||||
setTabValue(0); // Switch to results tab
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle save station
|
||||
const handleSave = (station: Station) => {
|
||||
saveStation(
|
||||
{
|
||||
placeId: station.placeId,
|
||||
data: { isFavorite: true }
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSearchResults((prev) =>
|
||||
prev.map((s) =>
|
||||
s.placeId === station.placeId ? { ...s } : s
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Handle delete station
|
||||
const handleDelete = (placeId: string) => {
|
||||
deleteStation(placeId);
|
||||
};
|
||||
|
||||
// If mobile, stack components vertically
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box sx={{ padding: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Paper>
|
||||
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
|
||||
</Paper>
|
||||
|
||||
{searchError && (
|
||||
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
|
||||
)}
|
||||
|
||||
<StationMap
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
currentLocation={currentLocation}
|
||||
center={mapCenter || undefined}
|
||||
height="300px"
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(_, newValue) => setTabValue(newValue)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
aria-label="stations tabs"
|
||||
>
|
||||
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
|
||||
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<StationsList
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
loading={isSearching}
|
||||
error={searchError ? (searchError as any).message : null}
|
||||
onSaveStation={handleSave}
|
||||
onDeleteStation={handleDelete}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<SavedStationsList
|
||||
stations={savedStations}
|
||||
onSelectStation={(station) => {
|
||||
setMapCenter({
|
||||
lat: station.latitude,
|
||||
lng: station.longitude
|
||||
});
|
||||
}}
|
||||
onDeleteStation={handleDelete}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout: side-by-side
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ padding: 2, height: 'calc(100vh - 80px)' }}>
|
||||
{/* Left: Map (60%) */}
|
||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Paper sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
<StationMap
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
currentLocation={currentLocation}
|
||||
center={mapCenter || undefined}
|
||||
height="100%"
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Right: Search + Tabs (40%) */}
|
||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Search Form */}
|
||||
<Paper>
|
||||
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
|
||||
</Paper>
|
||||
|
||||
{/* Error Alert */}
|
||||
{searchError && (
|
||||
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(_, newValue) => setTabValue(newValue)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
aria-label="stations tabs"
|
||||
>
|
||||
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
|
||||
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
|
||||
</Tabs>
|
||||
|
||||
{/* Tab Content with overflow */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<StationsList
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
loading={isSearching}
|
||||
error={searchError ? (searchError as any).message : null}
|
||||
onSaveStation={handleSave}
|
||||
onDeleteStation={handleDelete}
|
||||
onSelectStation={(station) => {
|
||||
setMapCenter({
|
||||
lat: station.latitude,
|
||||
lng: station.longitude
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<SavedStationsList
|
||||
stations={savedStations}
|
||||
onSelectStation={(station) => {
|
||||
setMapCenter({
|
||||
lat: station.latitude,
|
||||
lng: station.longitude
|
||||
});
|
||||
}}
|
||||
onDeleteStation={handleDelete}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationsPage;
|
||||
148
frontend/src/features/stations/types/google-maps.d.ts
vendored
Normal file
148
frontend/src/features/stations/types/google-maps.d.ts
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Type declarations for Google Maps API
|
||||
* These define the global google.maps namespace when the API is loaded dynamically
|
||||
*/
|
||||
|
||||
declare global {
|
||||
namespace google {
|
||||
namespace maps {
|
||||
/**
|
||||
* Google Maps Map instance
|
||||
*/
|
||||
class Map {
|
||||
constructor(
|
||||
container: HTMLElement | null,
|
||||
options?: google.maps.MapOptions
|
||||
);
|
||||
setCenter(latlng: google.maps.LatLng | google.maps.LatLngLiteral): void;
|
||||
setZoom(zoom: number): void;
|
||||
fitBounds(
|
||||
bounds: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral,
|
||||
padding?: number | { top: number; right: number; bottom: number; left: number }
|
||||
): void;
|
||||
getCenter(): google.maps.LatLng | undefined;
|
||||
getZoom(): number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps Marker
|
||||
*/
|
||||
class Marker {
|
||||
constructor(options?: google.maps.MarkerOptions);
|
||||
setMap(map: google.maps.Map | null): void;
|
||||
setPosition(latlng: google.maps.LatLng | google.maps.LatLngLiteral): void;
|
||||
getPosition(): google.maps.LatLng | undefined;
|
||||
setTitle(title: string): void;
|
||||
addListener(
|
||||
eventName: string,
|
||||
callback: (...args: any[]) => void
|
||||
): google.maps.MapsEventListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps InfoWindow
|
||||
*/
|
||||
class InfoWindow {
|
||||
constructor(options?: google.maps.InfoWindowOptions);
|
||||
open(map?: google.maps.Map | null, anchor?: google.maps.Marker): void;
|
||||
close(): void;
|
||||
setContent(content: string | HTMLElement): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps LatLng
|
||||
*/
|
||||
class LatLng {
|
||||
constructor(lat: number, lng: number);
|
||||
lat(): number;
|
||||
lng(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps LatLngBounds
|
||||
*/
|
||||
class LatLngBounds {
|
||||
constructor(sw?: google.maps.LatLng, ne?: google.maps.LatLng);
|
||||
extend(point: google.maps.LatLng): google.maps.LatLngBounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps Symbol Path enum
|
||||
*/
|
||||
enum SymbolPath {
|
||||
CIRCLE = 'CIRCLE',
|
||||
FORWARD_CLOSED_ARROW = 'FORWARD_CLOSED_ARROW',
|
||||
FORWARD_OPEN_ARROW = 'FORWARD_OPEN_ARROW',
|
||||
BACKWARD_CLOSED_ARROW = 'BACKWARD_CLOSED_ARROW',
|
||||
BACKWARD_OPEN_ARROW = 'BACKWARD_OPEN_ARROW'
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps Event Listener
|
||||
*/
|
||||
interface MapsEventListener {
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Options
|
||||
*/
|
||||
interface MapOptions {
|
||||
zoom?: number;
|
||||
center?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||
mapTypeId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker Options
|
||||
*/
|
||||
interface MarkerOptions {
|
||||
position?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||
map?: google.maps.Map;
|
||||
title?: string;
|
||||
icon?: string | google.maps.Icon;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Info Window Options
|
||||
*/
|
||||
interface InfoWindowOptions {
|
||||
content?: string | HTMLElement;
|
||||
position?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||
maxWidth?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon options
|
||||
*/
|
||||
interface Icon {
|
||||
url?: string;
|
||||
size?: { width: number; height: number };
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* LatLng Literal
|
||||
*/
|
||||
interface LatLngLiteral {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* LatLngBounds Literal
|
||||
*/
|
||||
interface LatLngBoundsLiteral {
|
||||
east: number;
|
||||
north: number;
|
||||
south: number;
|
||||
west: number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
139
frontend/src/features/stations/types/stations.types.ts
Normal file
139
frontend/src/features/stations/types/stations.types.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for Gas Stations feature
|
||||
*/
|
||||
|
||||
/**
|
||||
* Geographic location coordinates
|
||||
*/
|
||||
export interface Location {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gas station search request parameters
|
||||
*/
|
||||
export interface StationSearchRequest extends Location {
|
||||
/** Search radius in meters (default: 5000 = 5km) */
|
||||
radius?: number;
|
||||
/** Optional fuel type filter */
|
||||
fuelType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Station search response metadata
|
||||
*/
|
||||
export interface SearchLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single gas station from search results
|
||||
*/
|
||||
export interface Station {
|
||||
/** Google Places ID for the station */
|
||||
placeId: string;
|
||||
/** Station name (e.g. "Shell Downtown") */
|
||||
name: string;
|
||||
/** Full address of the station */
|
||||
address: string;
|
||||
/** Formatted address from Google Maps */
|
||||
formattedAddress?: string;
|
||||
/** Latitude coordinate */
|
||||
latitude: number;
|
||||
/** Longitude coordinate */
|
||||
longitude: number;
|
||||
/** Google rating (0-5 stars) */
|
||||
rating: number;
|
||||
/** Distance from search location in meters */
|
||||
distance?: number;
|
||||
/** URL to station photo if available */
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved/favorited station with user metadata
|
||||
*/
|
||||
export interface SavedStation extends Station {
|
||||
/** Database record ID */
|
||||
id: string;
|
||||
/** User ID who saved the station */
|
||||
userId: string;
|
||||
/** Custom nickname given by user */
|
||||
nickname?: string;
|
||||
/** User notes about the station */
|
||||
notes?: string;
|
||||
/** Whether station is marked as favorite */
|
||||
isFavorite: boolean;
|
||||
/** Created timestamp */
|
||||
createdAt: Date;
|
||||
/** Last updated timestamp */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Station search response with metadata
|
||||
*/
|
||||
export interface StationSearchResponse {
|
||||
/** Array of stations found */
|
||||
stations: Station[];
|
||||
/** Location where search was performed */
|
||||
searchLocation: SearchLocation;
|
||||
/** Radius used for search in meters */
|
||||
searchRadius: number;
|
||||
/** When search was performed */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data needed to save a station
|
||||
*/
|
||||
export interface SaveStationData {
|
||||
/** Custom nickname for the station */
|
||||
nickname?: string;
|
||||
/** User notes about the station */
|
||||
notes?: string;
|
||||
/** Whether to mark as favorite */
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker for map display
|
||||
*/
|
||||
export interface MapMarker {
|
||||
placeId: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
isSaved: boolean;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geolocation position from browser API
|
||||
*/
|
||||
export interface GeolocationCoordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geolocation status and error types
|
||||
*/
|
||||
export enum GeolocationError {
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
POSITION_UNAVAILABLE = 'POSITION_UNAVAILABLE',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
/**
|
||||
* API error response
|
||||
*/
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
73
frontend/src/features/stations/utils/distance.ts
Normal file
73
frontend/src/features/stations/utils/distance.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @ai-summary Distance calculation and formatting utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate distance between two coordinates using Haversine formula
|
||||
* Returns distance in meters
|
||||
*
|
||||
* @param lat1 Starting latitude
|
||||
* @param lon1 Starting longitude
|
||||
* @param lat2 Ending latitude
|
||||
* @param lon2 Ending longitude
|
||||
* @returns Distance in meters
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) *
|
||||
Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance for display
|
||||
* Shows miles if distance > 1609m, otherwise shows meters
|
||||
*
|
||||
* @param meters Distance in meters
|
||||
* @returns Formatted distance string (e.g., "1.2 mi" or "500 m")
|
||||
*/
|
||||
export function formatDistance(meters: number): string {
|
||||
const METERS_PER_MILE = 1609.34;
|
||||
|
||||
if (meters >= METERS_PER_MILE) {
|
||||
const miles = meters / METERS_PER_MILE;
|
||||
return `${miles.toFixed(1)} mi`;
|
||||
}
|
||||
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees to radians
|
||||
*/
|
||||
function toRad(degrees: number): number {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert miles to meters
|
||||
*/
|
||||
export function milesToMeters(miles: number): number {
|
||||
return miles * 1609.34;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert meters to miles
|
||||
*/
|
||||
export function metersToMiles(meters: number): number {
|
||||
return meters / 1609.34;
|
||||
}
|
||||
170
frontend/src/features/stations/utils/map-utils.ts
Normal file
170
frontend/src/features/stations/utils/map-utils.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @ai-summary Google Maps utility functions
|
||||
*/
|
||||
|
||||
import { getGoogleMapsApi } from './maps-loader';
|
||||
import { Station, MapMarker } from '../types/stations.types';
|
||||
import { formatDistance } from './distance';
|
||||
|
||||
/**
|
||||
* Create a marker for a station
|
||||
*
|
||||
* @param station Station data
|
||||
* @param map Google Map instance
|
||||
* @param isSaved Whether station is saved
|
||||
* @returns Google Maps Marker
|
||||
*/
|
||||
export function createStationMarker(
|
||||
station: Station,
|
||||
map: google.maps.Map,
|
||||
isSaved: boolean
|
||||
): google.maps.Marker {
|
||||
const maps = getGoogleMapsApi();
|
||||
const markerColor = isSaved ? '#FFD700' : '#4285F4'; // Gold for saved, blue for normal
|
||||
|
||||
const marker = new maps.Marker({
|
||||
position: {
|
||||
lat: station.latitude,
|
||||
lng: station.longitude
|
||||
},
|
||||
map,
|
||||
title: station.name,
|
||||
icon: {
|
||||
path: maps.SymbolPath.CIRCLE,
|
||||
scale: 8,
|
||||
fillColor: markerColor,
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#fff',
|
||||
strokeWeight: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Store station data on marker
|
||||
(marker as any).stationData = station;
|
||||
(marker as any).isSaved = isSaved;
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create info window for a station
|
||||
*
|
||||
* @param station Station data
|
||||
* @param isSaved Whether station is saved
|
||||
* @returns Google Maps InfoWindow
|
||||
*/
|
||||
export function createInfoWindow(
|
||||
station: Station,
|
||||
isSaved: boolean
|
||||
): google.maps.InfoWindow {
|
||||
const maps = getGoogleMapsApi();
|
||||
const distanceText = station.distance
|
||||
? `Distance: ${formatDistance(station.distance)}`
|
||||
: '';
|
||||
|
||||
const content = `
|
||||
<div style="font-family: Roboto, sans-serif; padding: 8px;">
|
||||
<h3 style="margin: 0 0 4px 0; font-size: 16px;">${station.name}</h3>
|
||||
<p style="margin: 4px 0; font-size: 12px; color: #666;">${station.address}</p>
|
||||
${
|
||||
distanceText
|
||||
? `<p style="margin: 4px 0; font-size: 12px; color: #666;">${distanceText}</p>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
station.rating
|
||||
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(1)}</p>`
|
||||
: ''
|
||||
}
|
||||
<div style="margin-top: 8px;">
|
||||
<a href="https://www.google.com/maps/search/${encodeURIComponent(station.address)}" target="_blank" style="color: #4285F4; text-decoration: none; font-size: 12px; margin-right: 8px;">
|
||||
Directions
|
||||
</a>
|
||||
${isSaved ? '<span style="color: #FFD700; font-size: 12px;">★ Saved</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return new maps.InfoWindow({
|
||||
content
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit map bounds to show all markers
|
||||
*
|
||||
* @param map Google Map instance
|
||||
* @param markers Array of markers
|
||||
*/
|
||||
export function fitBoundsToMarkers(
|
||||
map: google.maps.Map,
|
||||
markers: google.maps.Marker[]
|
||||
): void {
|
||||
if (markers.length === 0) return;
|
||||
|
||||
const maps = getGoogleMapsApi();
|
||||
const bounds = new maps.LatLngBounds();
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const position = marker.getPosition();
|
||||
if (position) {
|
||||
bounds.extend(position);
|
||||
}
|
||||
});
|
||||
|
||||
map.fitBounds(bounds);
|
||||
|
||||
// Add padding
|
||||
const padding = { top: 50, right: 50, bottom: 50, left: 50 };
|
||||
map.fitBounds(bounds, padding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create current location marker
|
||||
*
|
||||
* @param latitude Current latitude
|
||||
* @param longitude Current longitude
|
||||
* @param map Google Map instance
|
||||
* @returns Google Maps Marker
|
||||
*/
|
||||
export function createCurrentLocationMarker(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
map: google.maps.Map
|
||||
): google.maps.Marker {
|
||||
const maps = getGoogleMapsApi();
|
||||
|
||||
return new maps.Marker({
|
||||
position: {
|
||||
lat: latitude,
|
||||
lng: longitude
|
||||
},
|
||||
map,
|
||||
title: 'Your Location',
|
||||
icon: {
|
||||
path: maps.SymbolPath.CIRCLE,
|
||||
scale: 10,
|
||||
fillColor: '#FF0000',
|
||||
fillOpacity: 0.7,
|
||||
strokeColor: '#fff',
|
||||
strokeWeight: 2
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Station to MapMarker
|
||||
*/
|
||||
export function stationToMapMarker(
|
||||
station: Station,
|
||||
isSaved: boolean
|
||||
): MapMarker {
|
||||
return {
|
||||
placeId: station.placeId,
|
||||
name: station.name,
|
||||
latitude: station.latitude,
|
||||
longitude: station.longitude,
|
||||
isSaved,
|
||||
distance: station.distance
|
||||
};
|
||||
}
|
||||
86
frontend/src/features/stations/utils/maps-loader.ts
Normal file
86
frontend/src/features/stations/utils/maps-loader.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @ai-summary Google Maps JavaScript API loader
|
||||
* Handles dynamic loading and singleton pattern
|
||||
*/
|
||||
|
||||
import { getGoogleMapsApiKey } from '@/core/config/config.types';
|
||||
|
||||
let mapsPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Load Google Maps JavaScript API dynamically
|
||||
* Uses singleton pattern - only loads once
|
||||
*
|
||||
* @returns Promise that resolves when Google Maps is loaded
|
||||
*/
|
||||
export function loadGoogleMaps(): Promise<void> {
|
||||
// Return cached promise if already loading/loaded
|
||||
if (mapsPromise) {
|
||||
return mapsPromise;
|
||||
}
|
||||
|
||||
// Create loading promise
|
||||
mapsPromise = new Promise((resolve, reject) => {
|
||||
// Check if already loaded in window
|
||||
if ((window as any).google?.maps) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get API key from runtime config
|
||||
const apiKey = getGoogleMapsApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
reject(new Error('Google Maps API key is not configured'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create script tag
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
|
||||
script.onload = () => {
|
||||
if ((window as any).google?.maps) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(
|
||||
new Error('Google Maps loaded but window.google.maps not available')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
// Reset promise so retry is possible
|
||||
mapsPromise = null;
|
||||
reject(new Error('Failed to load Google Maps script'));
|
||||
};
|
||||
|
||||
// Add to document
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return mapsPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Maps API (after loading)
|
||||
*
|
||||
* @returns Google Maps object
|
||||
* @throws Error if Google Maps not loaded
|
||||
*/
|
||||
export function getGoogleMapsApi(): typeof google.maps {
|
||||
if (!(window as any).google?.maps) {
|
||||
throw new Error('Google Maps not loaded. Call loadGoogleMaps() first.');
|
||||
}
|
||||
|
||||
return (window as any).google.maps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset loader (for testing)
|
||||
*/
|
||||
export function resetMapsLoader(): void {
|
||||
mapsPromise = null;
|
||||
}
|
||||
Reference in New Issue
Block a user