Gas Station Feature
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user