Files
motovaultpro/frontend/src/features/stations/pages/StationsPage.tsx
2025-12-21 13:56:59 -06:00

547 lines
18 KiB
TypeScript

/**
* @ai-summary Desktop stations page with map and list layout
*/
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import {
Grid,
Paper,
Tabs,
Tab,
Box,
Alert,
useMediaQuery,
useTheme,
CircularProgress,
Typography
} from '@mui/material';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import { SavedStation, Station, StationSearchRequest } from '../types/stations.types';
import { CommunityStation, StationBounds } from '../types/community-stations.types';
import {
useStationsSearch,
useSavedStations,
useSaveStation,
useDeleteStation,
useGeolocation
} from '../hooks';
import {
StationMap,
StationsList,
SavedStationsList,
StationsSearchForm,
GoogleMapsErrorBoundary,
SubmitFor93Dialog,
Premium93TabContent
} from '../components';
import { resolveSavedStationPlaceId } from '../utils/savedStations';
import { useEnrichedStations } from '../hooks/useEnrichedStations';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
// Only render content when tab is active to prevent IntersectionObserver errors
// on hidden MUI components
if (value !== index) {
return null;
}
return (
<div
role="tabpanel"
id={`stations-tabpanel-${index}`}
aria-labelledby={`stations-tab-${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 = () => {
console.log('[DEBUG StationsPage] Rendering');
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
>();
const [isPageReady, setIsPageReady] = useState(false);
const [isMapReady, setIsMapReady] = useState(false);
const [submitFor93Station, setSubmitFor93Station] = useState<Station | null>(null);
const [searchBounds, setSearchBounds] = useState<StationBounds | null>(null);
// Queries and mutations
const { mutate: search, isPending: isSearching, error: searchError } = useStationsSearch();
const { data: savedStations = [], isLoading: isSavedLoading, error: savedError, isFetching, isSuccess } = useSavedStations();
console.log('[DEBUG StationsPage] useSavedStations result:', {
data: savedStations,
isLoading: isSavedLoading,
isFetching,
isSuccess,
error: savedError
});
// Get user's geolocation for community station lookups (fallback when no search performed)
const { coordinates: geoCoordinates } = useGeolocation();
// Effective coordinates: use search location if available, otherwise use geolocation
const effectiveLatitude = currentLocation?.latitude ?? geoCoordinates?.latitude ?? null;
const effectiveLongitude = currentLocation?.longitude ?? geoCoordinates?.longitude ?? null;
// Enrich search results with community station data
const { enrichedStations, communityStationsMap } = useEnrichedStations(
searchResults,
currentLocation?.latitude ?? null,
currentLocation?.longitude ?? null
);
// Multi-stage initialization: Wait for auth, data, and DOM
useEffect(() => {
// Stage 1: Wait for saved stations query to settle (loading complete or error)
const isDataReady = !isSavedLoading && !isFetching;
if (isDataReady && !isPageReady) {
console.log('[DEBUG StationsPage] Data ready, waiting for DOM...');
// Stage 2: Wait for DOM to be ready
const timer = setTimeout(() => {
setIsPageReady(true);
console.log('[DEBUG StationsPage] Page ready');
}, 150);
return () => clearTimeout(timer);
}
return undefined;
}, [isSavedLoading, isFetching, isPageReady]);
// Stage 3: Wait for page render to complete before allowing map initialization
useEffect(() => {
if (isPageReady && !isMapReady) {
console.log('[DEBUG StationsPage] Page rendered, enabling map...');
const timer = setTimeout(() => {
setIsMapReady(true);
console.log('[DEBUG StationsPage] Map ready');
}, 100);
return () => clearTimeout(timer);
}
return undefined;
}, [isPageReady, isMapReady]);
const { mutate: saveStation } = useSaveStation();
const { mutate: deleteStation } = useDeleteStation();
// Create set of saved place IDs and addresses for quick lookup
const { savedStationsMap, savedPlaceIds, savedAddresses } = useMemo(() => {
const map = new Map<string, SavedStation>();
const addresses = new Set<string>();
savedStations.forEach((station) => {
const placeId = resolveSavedStationPlaceId(station);
if (!placeId) {
return;
}
const normalizedStation =
station.placeId === placeId ? station : { ...station, placeId };
map.set(placeId, normalizedStation);
// Also track addresses for community station matching
if (station.address) {
addresses.add(station.address.toLowerCase().trim());
}
});
return {
savedStationsMap: map,
savedPlaceIds: new Set(map.keys()),
savedAddresses: addresses
};
}, [savedStations]);
// Compute which stations to display on map based on active tab
const mapStations = useMemo(() => {
if (tabValue === 1) {
// Saved tab: show saved stations with valid coordinates
return savedStations.filter(
(station) =>
station.latitude !== undefined &&
station.longitude !== undefined
) as Station[];
}
if (tabValue === 2) {
// Premium 93 tab: show saved stations with 93 octane
return savedStations.filter(
(station) =>
station.has93Octane &&
station.latitude !== undefined &&
station.longitude !== undefined
) as Station[];
}
// Results tab: show search results
return searchResults;
}, [tabValue, savedStations, searchResults]);
// Handle search
const handleSearch = (request: StationSearchRequest) => {
setCurrentLocation({ latitude: request.latitude, longitude: request.longitude });
setMapCenter({ lat: request.latitude, lng: request.longitude });
// Calculate approximate bounds from search location and radius
const radiusKm = (request.radius || 5000) / 1000;
const latDelta = radiusKm / 111; // ~111km per degree latitude
const lngDelta = radiusKm / (111 * Math.cos(request.latitude * Math.PI / 180));
setSearchBounds({
north: request.latitude + latDelta,
south: request.latitude - latDelta,
east: request.longitude + lngDelta,
west: request.longitude - lngDelta
});
search(request, {
onSuccess: (stations) => {
setSearchResults(stations);
setTabValue(0); // Switch to results tab
}
});
};
// Handle save station - auto-copies 93 octane data from community verification
const handleSave = (station: Station) => {
// Get community data for this station if available
const normalizedAddress = station.address
?.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.replace(/[,]/g, '') || '';
const communityData = communityStationsMap.get(normalizedAddress);
saveStation(
{
placeId: station.placeId,
data: {
isFavorite: true,
has93Octane: communityData?.has93Octane,
has93OctaneEthanolFree: communityData?.has93OctaneEthanolFree
}
},
{
onSuccess: () => {
setSearchResults((prev) =>
prev.map((s) =>
s.placeId === station.placeId ? { ...s } : s
)
);
}
}
);
};
// Handle save community station (auto-sets has93Octane flag)
const handleSaveCommunityStation = useCallback((station: CommunityStation) => {
saveStation({
placeId: station.id, // Use community station ID as placeId
data: {
isFavorite: true,
has93Octane: station.has93Octane,
has93OctaneEthanolFree: station.has93OctaneEthanolFree
}
});
}, [saveStation]);
// Handle unsave community station
const handleUnsaveCommunityStation = useCallback((stationId: string) => {
deleteStation(stationId);
}, [deleteStation]);
// Handle delete station
const handleDelete = (placeId: string) => {
deleteStation(placeId);
};
// Handle station selection - wrapped in useCallback to prevent infinite renders
const handleSelectStation = useCallback((station: Station | CommunityStation) => {
setMapCenter({
lat: station.latitude,
lng: station.longitude
});
}, []);
// Show comprehensive loading state until everything is ready
if (!isPageReady || !isMapReady) {
return (
<Box
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '400px',
gap: 2
}}
>
<CircularProgress size={48} />
<Typography variant="body1" color="textSecondary">
Loading stations...
</Typography>
</Box>
);
}
// 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>
)}
{isMapReady ? (
<GoogleMapsErrorBoundary>
<StationMap
key="mobile-station-map"
stations={mapStations}
savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation}
center={mapCenter || undefined}
height="300px"
readyToRender={true}
/>
</GoogleMapsErrorBoundary>
) : (
<Box sx={{ height: '300px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
)}
<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" />
<Tab
label="Premium 93"
icon={<LocalGasStationIcon />}
iconPosition="start"
id="stations-tab-2"
/>
</Tabs>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={enrichedStations}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
onDeleteStation={handleDelete}
communityStationsMap={communityStationsMap}
onSubmitFor93={(station) => setSubmitFor93Station(station)}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<SavedStationsList
stations={savedStations}
loading={isSavedLoading}
error={savedError ? (savedError as any).message : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDelete}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
communityStationsMap={communityStationsMap}
latitude={effectiveLatitude}
longitude={effectiveLongitude}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<Premium93TabContent
latitude={effectiveLatitude}
longitude={effectiveLongitude}
savedStations={savedStations}
communityStationsMap={communityStationsMap}
onStationSelect={handleSelectStation}
searchBounds={searchBounds}
onSaveCommunityStation={handleSaveCommunityStation}
onUnsaveCommunityStation={handleUnsaveCommunityStation}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
savedAddresses={savedAddresses}
/>
</TabPanel>
</Box>
);
}
// Desktop layout: top-row map + search, full-width results
return (
<Box sx={{ padding: 2 }}>
<Grid container spacing={2}>
{/* Map */}
<Grid item xs={12} md={6}>
<Paper
sx={{
height: { xs: 300, md: 520 },
display: 'flex',
overflow: 'hidden'
}}
>
{isMapReady ? (
<GoogleMapsErrorBoundary>
<StationMap
key="desktop-station-map"
stations={mapStations}
savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation}
center={mapCenter || undefined}
height="100%"
readyToRender={true}
/>
</GoogleMapsErrorBoundary>
) : (
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
)}
</Paper>
</Grid>
{/* Search form */}
<Grid item xs={12} md={6}>
<Paper
sx={{
height: { xs: 'auto', md: 520 },
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<Box sx={{ padding: 2, flex: 1 }}>
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
</Box>
</Paper>
</Grid>
</Grid>
{/* Error Alert */}
{searchError && (
<Box sx={{ mt: 2 }}>
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
</Box>
)}
{/* Full-width Results */}
<Paper
sx={{
mt: 2,
display: 'flex',
flexDirection: 'column'
}}
>
<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" />
<Tab
label="Premium 93"
icon={<LocalGasStationIcon />}
iconPosition="start"
id="stations-tab-2"
/>
</Tabs>
<Box sx={{ flex: 1, overflow: 'auto', padding: 2 }}>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={enrichedStations}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
onDeleteStation={handleDelete}
onSelectStation={handleSelectStation}
communityStationsMap={communityStationsMap}
onSubmitFor93={(station) => setSubmitFor93Station(station)}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<SavedStationsList
stations={savedStations}
loading={isSavedLoading}
error={savedError ? (savedError as any).message : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDelete}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
communityStationsMap={communityStationsMap}
latitude={effectiveLatitude}
longitude={effectiveLongitude}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<Premium93TabContent
latitude={effectiveLatitude}
longitude={effectiveLongitude}
savedStations={savedStations}
communityStationsMap={communityStationsMap}
onStationSelect={handleSelectStation}
searchBounds={searchBounds}
onSaveCommunityStation={handleSaveCommunityStation}
onUnsaveCommunityStation={handleUnsaveCommunityStation}
onSubmitFor93={(station) => setSubmitFor93Station(station as unknown as Station)}
savedAddresses={savedAddresses}
/>
</TabPanel>
</Box>
</Paper>
<SubmitFor93Dialog
open={!!submitFor93Station}
onClose={() => setSubmitFor93Station(null)}
station={submitFor93Station}
communityStationId={
submitFor93Station
? // If it's a CommunityStation from Premium 93 tab, use its id directly
('status' in submitFor93Station && submitFor93Station.status === 'approved')
? (submitFor93Station as unknown as CommunityStation).id
// Otherwise look up in map (for search results)
: communityStationsMap.get(submitFor93Station.address?.toLowerCase().trim() || '')?.communityStationId
: undefined
}
/>
</Box>
);
};
export default StationsPage;