547 lines
18 KiB
TypeScript
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;
|