Google Maps Bug
This commit is contained in:
@@ -16,7 +16,7 @@ This approach:
|
||||
|
||||
1. **Build Time**: Container is built WITHOUT secrets (no API keys in image)
|
||||
2. **Container Startup**:
|
||||
- `/app/load-config.sh` reads `/run/secrets/google-maps-api-key`
|
||||
- `/app/load-config.sh` reads `/run/secrets/google-maps-api-key` and `/run/secrets/google-maps-map-id`
|
||||
- Generates `/usr/share/nginx/html/config.js` with runtime values
|
||||
- Starts nginx
|
||||
3. **App Load Time**:
|
||||
@@ -86,6 +86,7 @@ fi
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
window.CONFIG = {
|
||||
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
|
||||
googleMapsMapId: '$GOOGLE_MAPS_MAP_ID',
|
||||
newApiKey: '$NEW_API_KEY'
|
||||
};
|
||||
EOF
|
||||
@@ -96,6 +97,7 @@ EOF
|
||||
```typescript
|
||||
export interface AppConfig {
|
||||
googleMapsApiKey: string;
|
||||
googleMapsMapId?: string;
|
||||
newApiKey: string; // Add new field
|
||||
}
|
||||
|
||||
@@ -108,6 +110,16 @@ export function getNewApiKey(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getGoogleMapsMapId(): string {
|
||||
try {
|
||||
const config = getConfig();
|
||||
return config.googleMapsMapId || '';
|
||||
} catch {
|
||||
console.warn('Google Maps Map ID not available.');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update docker-compose.yml
|
||||
@@ -116,6 +128,7 @@ export function getNewApiKey(): string {
|
||||
mvp-frontend:
|
||||
volumes:
|
||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||
- ./secrets/app/new-api-key.txt:/run/secrets/new-api-key:ro # Add new secret
|
||||
```
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ set -e
|
||||
SECRETS_DIR="${SECRETS_DIR:-/run/secrets}"
|
||||
CONFIG_FILE="/usr/share/nginx/html/config.js"
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
GOOGLE_MAPS_MAP_ID=""
|
||||
|
||||
# Try to read Google Maps API key from secret file
|
||||
if [ -f "$SECRETS_DIR/google-maps-api-key" ]; then
|
||||
@@ -17,10 +18,20 @@ else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
|
||||
# Try to read Google Maps Map ID (optional)
|
||||
if [ -f "$SECRETS_DIR/google-maps-map-id" ]; then
|
||||
GOOGLE_MAPS_MAP_ID=$(cat "$SECRETS_DIR/google-maps-map-id")
|
||||
echo "[Config] Loaded Google Maps Map ID from $SECRETS_DIR/google-maps-map-id"
|
||||
else
|
||||
echo "[Config] Info: Google Maps Map ID not found at $SECRETS_DIR/google-maps-map-id (advanced markers require this)"
|
||||
GOOGLE_MAPS_MAP_ID=""
|
||||
fi
|
||||
|
||||
# Generate config.js
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
window.CONFIG = {
|
||||
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY'
|
||||
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
|
||||
googleMapsMapId: '$GOOGLE_MAPS_MAP_ID'
|
||||
};
|
||||
EOF
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
export interface AppConfig {
|
||||
/** Google Maps JavaScript API key for map visualization */
|
||||
googleMapsApiKey: string;
|
||||
/** Google Maps Map ID for vector basemap and advanced markers */
|
||||
googleMapsMapId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,21 @@ export function getGoogleMapsApiKey(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Maps Map ID (optional)
|
||||
*
|
||||
* @returns Google Maps Map ID or empty string
|
||||
*/
|
||||
export function getGoogleMapsMapId(): string {
|
||||
try {
|
||||
const config = getConfig();
|
||||
return config.googleMapsMapId || '';
|
||||
} catch {
|
||||
console.warn('Google Maps Map ID not available. Advanced map features may be limited.');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if configuration is available
|
||||
* Useful for conditional feature enablement
|
||||
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
import { useSavedStations } from '../../stations/hooks/useSavedStations';
|
||||
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
|
||||
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
|
||||
import {
|
||||
resolveSavedStationAddress,
|
||||
resolveSavedStationCoordinates,
|
||||
resolveSavedStationName,
|
||||
resolveSavedStationPlaceId
|
||||
} from '../../stations/utils/savedStations';
|
||||
import { LocationData } from '../types/fuel-logs.types';
|
||||
|
||||
interface StationPickerProps {
|
||||
@@ -121,10 +127,18 @@ export const StationPicker: React.FC<StationPickerProps> = ({
|
||||
// Add saved stations first
|
||||
if (savedStations && savedStations.length > 0) {
|
||||
savedStations.forEach((station) => {
|
||||
const placeId = resolveSavedStationPlaceId(station);
|
||||
if (!placeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedStation =
|
||||
station.placeId === placeId ? station : { ...station, placeId };
|
||||
|
||||
opts.push({
|
||||
type: 'saved',
|
||||
station,
|
||||
label: station.nickname || station.name,
|
||||
station: normalizedStation,
|
||||
label: resolveSavedStationName(normalizedStation),
|
||||
group: 'Saved Stations'
|
||||
});
|
||||
});
|
||||
@@ -133,7 +147,11 @@ export const StationPicker: React.FC<StationPickerProps> = ({
|
||||
// Add nearby stations
|
||||
if (nearbyStations && nearbyStations.length > 0) {
|
||||
// Filter out stations already in saved list
|
||||
const savedPlaceIds = new Set(savedStations?.map((s) => s.placeId) || []);
|
||||
const savedPlaceIds = new Set(
|
||||
(savedStations || [])
|
||||
.map((station) => resolveSavedStationPlaceId(station))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
);
|
||||
|
||||
nearbyStations
|
||||
.filter((station) => !savedPlaceIds.has(station.placeId))
|
||||
@@ -171,16 +189,37 @@ export const StationPicker: React.FC<StationPickerProps> = ({
|
||||
// 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
|
||||
const saved = isSavedStation(station);
|
||||
const placeId = saved
|
||||
? resolveSavedStationPlaceId(station) || station.placeId
|
||||
: station.placeId;
|
||||
const name = saved ? resolveSavedStationName(station) : station.name;
|
||||
const address = saved ? resolveSavedStationAddress(station) : station.address;
|
||||
|
||||
let latitude = station.latitude;
|
||||
let longitude = station.longitude;
|
||||
|
||||
if ((latitude === undefined || longitude === undefined) && saved) {
|
||||
const coords = resolveSavedStationCoordinates(station);
|
||||
if (coords) {
|
||||
latitude = coords.latitude;
|
||||
longitude = coords.longitude;
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
stationName: name,
|
||||
address,
|
||||
googlePlaceId: placeId,
|
||||
coordinates:
|
||||
latitude !== undefined && longitude !== undefined
|
||||
? {
|
||||
latitude,
|
||||
longitude
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
setInputValue(station.name);
|
||||
setInputValue(name);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
|
||||
@@ -595,17 +595,18 @@ The Gas Stations feature uses MotoVaultPro's K8s-aligned runtime configuration p
|
||||
### Accessing Configuration
|
||||
|
||||
```typescript
|
||||
import { getGoogleMapsApiKey } from '@/core/config/config.types';
|
||||
import { getGoogleMapsApiKey, getGoogleMapsMapId } from '@/core/config/config.types';
|
||||
|
||||
export function MyComponent() {
|
||||
const apiKey = getGoogleMapsApiKey();
|
||||
const mapId = getGoogleMapsMapId();
|
||||
|
||||
if (!apiKey) {
|
||||
return <div>Google Maps API key not configured</div>;
|
||||
if (!apiKey || !mapId) {
|
||||
return <div>Google Maps configuration not complete</div>;
|
||||
}
|
||||
|
||||
// Use API key
|
||||
return <MapComponent apiKey={apiKey} />;
|
||||
// Use API key + map id
|
||||
return <StationMap apiKey={apiKey} mapId={mapId} />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -617,9 +618,11 @@ For local development (Vite dev server):
|
||||
# Set up secrets
|
||||
mkdir -p ./secrets/app
|
||||
echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
|
||||
echo "YOUR_MAP_ID" > ./secrets/app/google-maps-map-id.txt
|
||||
|
||||
# Alternatively, set environment variable
|
||||
export VITE_GOOGLE_MAPS_API_KEY=YOUR_API_KEY
|
||||
export VITE_GOOGLE_MAPS_MAP_ID=YOUR_MAP_ID
|
||||
```
|
||||
|
||||
See `/frontend/docs/RUNTIME-CONFIG.md` for complete documentation.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @ai-summary Selector for marking 93 octane availability on a saved station
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
FormHelperText
|
||||
} from '@mui/material';
|
||||
import { OctanePreference } from '../types/stations.types';
|
||||
|
||||
interface OctanePreferenceSelectorProps {
|
||||
value: OctanePreference;
|
||||
onChange: (value: OctanePreference) => void;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const LABEL_ID = 'octane-preference-select';
|
||||
|
||||
export const OctanePreferenceSelector: React.FC<OctanePreferenceSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
helperText,
|
||||
label = '93 Octane'
|
||||
}) => {
|
||||
const handleChange = (event: SelectChangeEvent) => {
|
||||
onChange(event.target.value as OctanePreference);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl size="small" fullWidth disabled={disabled}>
|
||||
<InputLabel id={LABEL_ID}>{label}</InputLabel>
|
||||
<Select
|
||||
labelId={LABEL_ID}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<MenuItem value="none">Not set</MenuItem>
|
||||
<MenuItem value="with_ethanol">93 w/ Ethanol</MenuItem>
|
||||
<MenuItem value="ethanol_free">93 Ethanol Free</MenuItem>
|
||||
</Select>
|
||||
{helperText && <FormHelperText>{helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default OctanePreferenceSelector;
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @ai-summary List of user's saved/favorited stations
|
||||
* @ai-summary List of user's saved/favorited stations with octane metadata editing
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -18,8 +18,15 @@ import {
|
||||
Skeleton
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { SavedStation } from '../types/stations.types';
|
||||
import { OctanePreference, SavedStation } from '../types/stations.types';
|
||||
import { formatDistance } from '../utils/distance';
|
||||
import {
|
||||
getOctanePreferenceFromFlags,
|
||||
resolveSavedStationAddress,
|
||||
resolveSavedStationName,
|
||||
resolveSavedStationPlaceId
|
||||
} from '../utils/savedStations';
|
||||
import { OctanePreferenceSelector } from './OctanePreferenceSelector';
|
||||
|
||||
interface SavedStationsListProps {
|
||||
stations: SavedStation[];
|
||||
@@ -27,19 +34,19 @@ interface SavedStationsListProps {
|
||||
error?: string | null;
|
||||
onSelectStation?: (station: SavedStation) => void;
|
||||
onDeleteStation?: (placeId: string) => void;
|
||||
onOctanePreferenceChange?: (placeId: string, preference: OctanePreference) => void;
|
||||
octaneUpdatingId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical list of saved stations with delete option
|
||||
*/
|
||||
export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
||||
stations,
|
||||
loading = false,
|
||||
error = null,
|
||||
onSelectStation,
|
||||
onDeleteStation
|
||||
onDeleteStation,
|
||||
onOctanePreferenceChange,
|
||||
octaneUpdatingId
|
||||
}) => {
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||
@@ -56,7 +63,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ padding: 2 }}>
|
||||
@@ -65,7 +71,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (stations.length === 0) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', padding: 3 }}>
|
||||
@@ -84,97 +89,118 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
||||
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 }}
|
||||
{stations.map((station, index) => {
|
||||
const placeId = resolveSavedStationPlaceId(station);
|
||||
const octanePreference = getOctanePreferenceFromFlags(
|
||||
station.has93Octane ?? false,
|
||||
station.has93OctaneEthanolFree ?? false
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={placeId ?? station.id}>
|
||||
<ListItem
|
||||
disablePadding
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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'
|
||||
}}
|
||||
<ListItemButton
|
||||
onClick={() => onSelectStation?.(station)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{index < stations.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
component="span"
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
{resolveSavedStationName(station)}
|
||||
</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">
|
||||
{resolveSavedStationAddress(station)}
|
||||
</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>
|
||||
)}
|
||||
{placeId && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<OctanePreferenceSelector
|
||||
value={octanePreference}
|
||||
onChange={(value) => onOctanePreferenceChange?.(placeId, value)}
|
||||
disabled={!onOctanePreferenceChange || octaneUpdatingId === placeId}
|
||||
helperText="Show on search cards"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (placeId) {
|
||||
onDeleteStation?.(placeId);
|
||||
}
|
||||
}}
|
||||
title="Delete saved station"
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
disabled={!placeId}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{index < stations.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,12 +16,13 @@ import {
|
||||
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 { Station, SavedStation } from '../types/stations.types';
|
||||
import { formatDistance } from '../utils/distance';
|
||||
|
||||
interface StationCardProps {
|
||||
station: Station;
|
||||
isSaved: boolean;
|
||||
savedStation?: SavedStation;
|
||||
onSave?: (station: Station) => void;
|
||||
onDelete?: (placeId: string) => void;
|
||||
onSelect?: (station: Station) => void;
|
||||
@@ -34,6 +35,7 @@ interface StationCardProps {
|
||||
export const StationCard: React.FC<StationCardProps> = ({
|
||||
station,
|
||||
isSaved,
|
||||
savedStation,
|
||||
onSave,
|
||||
onDelete,
|
||||
onSelect
|
||||
@@ -53,6 +55,19 @@ export const StationCard: React.FC<StationCardProps> = ({
|
||||
window.open(mapsUrl, '_blank');
|
||||
};
|
||||
|
||||
const savedMetadata = savedStation
|
||||
? {
|
||||
has93Octane: savedStation.has93Octane,
|
||||
has93OctaneEthanolFree: savedStation.has93OctaneEthanolFree
|
||||
}
|
||||
: station.savedMetadata;
|
||||
|
||||
const octaneLabel = savedMetadata?.has93Octane
|
||||
? savedMetadata.has93OctaneEthanolFree
|
||||
? '93 Octane · Ethanol Free'
|
||||
: '93 Octane · w/ Ethanol'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={() => onSelect?.(station)}
|
||||
@@ -127,6 +142,16 @@ export const StationCard: React.FC<StationCardProps> = ({
|
||||
sx={{ marginBottom: 1 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 93 Octane metadata */}
|
||||
{octaneLabel && (
|
||||
<Chip
|
||||
label={octaneLabel}
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ marginTop: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createInfoWindow,
|
||||
fitBoundsToMarkers
|
||||
} from '../utils/map-utils';
|
||||
import { getGoogleMapsMapId } from '@/core/config/config.types';
|
||||
|
||||
interface StationMapProps {
|
||||
stations: Station[];
|
||||
@@ -41,9 +42,9 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
}) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<google.maps.Map | null>(null);
|
||||
const markers = useRef<google.maps.Marker[]>([]);
|
||||
const markers = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
|
||||
const infoWindows = useRef<google.maps.InfoWindow[]>([]);
|
||||
const currentLocationMarker = useRef<google.maps.Marker | null>(null);
|
||||
const currentLocationMarker = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
|
||||
const isInitializing = useRef<boolean>(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -89,13 +90,28 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
|
||||
// Create map
|
||||
const defaultCenter = center || {
|
||||
lat: currentLocation?.latitude || 37.7749,
|
||||
lng: currentLocation?.longitude || -122.4194
|
||||
lat: currentLocation?.latitude || 43.074734,
|
||||
lng: currentLocation?.longitude || -89.384271
|
||||
};
|
||||
|
||||
if (mapIdRef.current === null) {
|
||||
const mapId = getGoogleMapsMapId();
|
||||
if (!mapId) {
|
||||
console.error(
|
||||
'[StationMap] Google Maps Map ID is not configured. Add google-maps-map-id secret to enable advanced markers.'
|
||||
);
|
||||
setError('Google Maps Map ID is not configured. Please contact support.');
|
||||
isInitializing.current = false;
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
mapIdRef.current = mapId;
|
||||
}
|
||||
|
||||
map.current = new maps.Map(mapContainer.current, {
|
||||
zoom,
|
||||
center: defaultCenter,
|
||||
mapId: mapIdRef.current || undefined,
|
||||
mapTypeControl: true,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: true
|
||||
@@ -180,7 +196,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
try {
|
||||
markers.current.forEach((marker) => {
|
||||
try {
|
||||
marker.setMap(null);
|
||||
marker.map = null;
|
||||
} catch (e) {
|
||||
// Ignore individual marker cleanup errors
|
||||
}
|
||||
@@ -205,7 +221,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
|
||||
try {
|
||||
if (currentLocationMarker.current) {
|
||||
currentLocationMarker.current.setMap(null);
|
||||
currentLocationMarker.current.map = null;
|
||||
currentLocationMarker.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -232,13 +248,15 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
}
|
||||
|
||||
// Clear old markers and info windows
|
||||
markers.current.forEach((marker) => marker.setMap(null));
|
||||
markers.current.forEach((marker) => {
|
||||
marker.map = null;
|
||||
});
|
||||
infoWindows.current.forEach((iw) => iw.close());
|
||||
markers.current = [];
|
||||
infoWindows.current = [];
|
||||
|
||||
getGoogleMapsApi();
|
||||
let allMarkers: google.maps.Marker[] = [];
|
||||
let allMarkers: google.maps.marker.AdvancedMarkerElement[] = [];
|
||||
|
||||
// Add station markers
|
||||
stations.forEach((station) => {
|
||||
@@ -256,7 +274,11 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
infoWindows.current.forEach((iw) => iw.close());
|
||||
|
||||
// Open this one
|
||||
infoWindow.open(map.current, marker);
|
||||
infoWindow.open({
|
||||
anchor: marker,
|
||||
map: map.current!,
|
||||
shouldFocus: false
|
||||
});
|
||||
onMarkerClick?.(station);
|
||||
});
|
||||
});
|
||||
@@ -264,7 +286,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
// Add current location marker
|
||||
if (currentLocation) {
|
||||
if (currentLocationMarker.current) {
|
||||
currentLocationMarker.current.setMap(null);
|
||||
currentLocationMarker.current.map = null;
|
||||
}
|
||||
|
||||
currentLocationMarker.current = createCurrentLocationMarker(
|
||||
@@ -346,3 +368,4 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
};
|
||||
|
||||
export default StationMap;
|
||||
const mapIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
Alert,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { Station } from '../types/stations.types';
|
||||
import { Station, SavedStation } from '../types/stations.types';
|
||||
import StationCard from './StationCard';
|
||||
|
||||
interface StationsListProps {
|
||||
stations: Station[];
|
||||
savedPlaceIds?: Set<string>;
|
||||
savedStationsMap?: Map<string, SavedStation>;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onSaveStation?: (station: Station) => void;
|
||||
@@ -32,6 +33,7 @@ interface StationsListProps {
|
||||
export const StationsList: React.FC<StationsListProps> = ({
|
||||
stations,
|
||||
savedPlaceIds = new Set(),
|
||||
savedStationsMap,
|
||||
loading = false,
|
||||
error = null,
|
||||
onSaveStation,
|
||||
@@ -92,6 +94,7 @@ export const StationsList: React.FC<StationsListProps> = ({
|
||||
<StationCard
|
||||
station={station}
|
||||
isSaved={savedPlaceIds.has(station.placeId)}
|
||||
savedStation={savedStationsMap?.get(station.placeId)}
|
||||
onSave={onSaveStation}
|
||||
onDelete={onDeleteStation}
|
||||
onSelect={onSelectStation}
|
||||
|
||||
@@ -298,7 +298,7 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
||||
setCity(e.target.value);
|
||||
markManualAddressInput();
|
||||
}}
|
||||
placeholder="San Francisco"
|
||||
placeholder="Madison"
|
||||
autoComplete="address-level2"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
export { useStationsSearch } from './useStationsSearch';
|
||||
export { useSavedStations, useInvalidateSavedStations, useUpdateSavedStationsCache } from './useSavedStations';
|
||||
export { useSaveStation } from './useSaveStation';
|
||||
export { useUpdateSavedStation } from './useUpdateSavedStation';
|
||||
export { useDeleteStation } from './useDeleteStation';
|
||||
export { useGeolocation } from './useGeolocation';
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* @ai-summary Hook for deleting saved stations
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { stationsApi } from '../api/stations.api';
|
||||
import { SavedStation, ApiError } from '../types/stations.types';
|
||||
import { useUpdateSavedStationsCache } from './useSavedStations';
|
||||
import { useSavedStationsQueryKey, useUpdateSavedStationsCache } from './useSavedStations';
|
||||
import { resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||
|
||||
interface UseDeleteStationOptions {
|
||||
onSuccess?: (placeId: string) => void;
|
||||
@@ -27,6 +28,8 @@ interface UseDeleteStationOptions {
|
||||
*/
|
||||
export function useDeleteStation(options?: UseDeleteStationOptions) {
|
||||
const updateCache = useUpdateSavedStationsCache();
|
||||
const queryClient = useQueryClient();
|
||||
const savedStationsKey = useSavedStationsQueryKey();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (placeId: string) => {
|
||||
@@ -41,12 +44,18 @@ export function useDeleteStation(options?: UseDeleteStationOptions) {
|
||||
updateCache((old) => {
|
||||
previousStations = old;
|
||||
if (!old) return [];
|
||||
return old.filter((s) => s.placeId !== placeId);
|
||||
return old.filter((station) => {
|
||||
const stationPlaceId = resolveSavedStationPlaceId(station);
|
||||
return stationPlaceId !== placeId;
|
||||
});
|
||||
});
|
||||
|
||||
return { previousStations, placeId };
|
||||
},
|
||||
onSuccess: (placeId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: savedStationsKey
|
||||
});
|
||||
options?.onSuccess?.(placeId);
|
||||
},
|
||||
onError: (error, _placeId, context) => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export function useSaveStation(options?: UseSaveStationOptions) {
|
||||
// Create optimistic station entry
|
||||
const optimisticStation: SavedStation = {
|
||||
id: `temp-${placeId}`,
|
||||
savedStationId: `temp-${placeId}`,
|
||||
placeId,
|
||||
name: data.nickname || 'New Station',
|
||||
address: '',
|
||||
@@ -65,6 +66,8 @@ export function useSaveStation(options?: UseSaveStationOptions) {
|
||||
nickname: data.nickname,
|
||||
notes: data.notes,
|
||||
isFavorite: data.isFavorite ?? false,
|
||||
has93Octane: data.has93Octane ?? false,
|
||||
has93OctaneEthanolFree: data.has93OctaneEthanolFree ?? false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @ai-summary Hook for updating saved station metadata
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { stationsApi } from '../api/stations.api';
|
||||
import { ApiError, SavedStation, SaveStationData } from '../types/stations.types';
|
||||
import { useUpdateSavedStationsCache } from './useSavedStations';
|
||||
import { resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||
|
||||
interface UpdateSavedStationVariables {
|
||||
placeId: string;
|
||||
data: Partial<SaveStationData>;
|
||||
}
|
||||
|
||||
interface UseUpdateSavedStationOptions {
|
||||
onSuccess?: (station: SavedStation) => void;
|
||||
onError?: (error: ApiError) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook to update saved station metadata (octane flags, nickname, etc.)
|
||||
*/
|
||||
export function useUpdateSavedStation(options?: UseUpdateSavedStationOptions) {
|
||||
const updateCache = useUpdateSavedStationsCache();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ placeId, data }: UpdateSavedStationVariables) => {
|
||||
return stationsApi.updateSavedStation(placeId, data);
|
||||
},
|
||||
onMutate: async ({ placeId, data }) => {
|
||||
let previousStations: SavedStation[] | undefined;
|
||||
|
||||
updateCache((old) => {
|
||||
previousStations = old;
|
||||
if (!old) return [];
|
||||
|
||||
return old.map((station) => {
|
||||
const stationPlaceId = resolveSavedStationPlaceId(station);
|
||||
if (stationPlaceId !== placeId) {
|
||||
return station;
|
||||
}
|
||||
|
||||
return {
|
||||
...station,
|
||||
...data,
|
||||
has93Octane:
|
||||
data.has93Octane !== undefined ? data.has93Octane : station.has93Octane,
|
||||
has93OctaneEthanolFree:
|
||||
data.has93OctaneEthanolFree !== undefined
|
||||
? data.has93OctaneEthanolFree
|
||||
: station.has93OctaneEthanolFree
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return { previousStations };
|
||||
},
|
||||
onSuccess: (station) => {
|
||||
options?.onSuccess?.(station);
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previousStations) {
|
||||
updateCache(() => context.previousStations || []);
|
||||
}
|
||||
|
||||
options?.onError?.(error as ApiError);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -30,14 +30,17 @@ import {
|
||||
useSavedStations,
|
||||
useSaveStation,
|
||||
useDeleteStation,
|
||||
useUpdateSavedStation,
|
||||
useGeolocation
|
||||
} from '../hooks';
|
||||
|
||||
import {
|
||||
Station,
|
||||
SavedStation,
|
||||
StationSearchRequest
|
||||
StationSearchRequest,
|
||||
OctanePreference
|
||||
} from '../types/stations.types';
|
||||
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||
|
||||
// Tab indices
|
||||
const TAB_SEARCH = 0;
|
||||
@@ -56,6 +59,7 @@ export const StationsMobileScreen: React.FC = () => {
|
||||
// Bottom sheet state
|
||||
const [selectedStation, setSelectedStation] = useState<Station | SavedStation | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
|
||||
|
||||
// Hooks
|
||||
const { coordinates } = useGeolocation();
|
||||
@@ -74,10 +78,28 @@ export const StationsMobileScreen: React.FC = () => {
|
||||
|
||||
const { mutateAsync: saveStation } = useSaveStation();
|
||||
const { mutateAsync: deleteStation } = useDeleteStation();
|
||||
const { mutateAsync: updateSavedStation } = useUpdateSavedStation();
|
||||
|
||||
// Compute set of saved place IDs for quick lookup
|
||||
const savedPlaceIds = useMemo(() => {
|
||||
return new Set(savedStations?.map(s => s.placeId) || []);
|
||||
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
|
||||
const map = new Map<string, SavedStation>();
|
||||
|
||||
(savedStations || []).forEach((station) => {
|
||||
const placeId = resolveSavedStationPlaceId(station);
|
||||
if (!placeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedStation =
|
||||
station.placeId === placeId ? station : { ...station, placeId };
|
||||
|
||||
map.set(placeId, normalizedStation);
|
||||
});
|
||||
|
||||
return {
|
||||
savedStationsMap: map,
|
||||
savedPlaceIds: new Set(map.keys())
|
||||
};
|
||||
}, [savedStations]);
|
||||
|
||||
// Handle search submission
|
||||
@@ -121,6 +143,21 @@ export const StationsMobileScreen: React.FC = () => {
|
||||
}
|
||||
}, [deleteStation, selectedStation]);
|
||||
|
||||
const handleOctanePreferenceChange = useCallback(
|
||||
async (placeId: string, preference: OctanePreference) => {
|
||||
try {
|
||||
setOctaneUpdatingId(placeId);
|
||||
const data = octanePreferenceToFlags(preference);
|
||||
await updateSavedStation({ placeId, data });
|
||||
} catch (error) {
|
||||
console.error('Failed to update octane preference:', error);
|
||||
} finally {
|
||||
setOctaneUpdatingId((current) => (current === placeId ? null : current));
|
||||
}
|
||||
},
|
||||
[updateSavedStation]
|
||||
);
|
||||
|
||||
// Close bottom sheet
|
||||
const handleCloseDrawer = useCallback(() => {
|
||||
setDrawerOpen(false);
|
||||
@@ -214,6 +251,7 @@ export const StationsMobileScreen: React.FC = () => {
|
||||
<StationsList
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
savedStationsMap={savedStationsMap}
|
||||
loading={isSearching}
|
||||
error={searchError ? 'Failed to search stations' : null}
|
||||
onSaveStation={handleSaveStation}
|
||||
@@ -235,6 +273,8 @@ export const StationsMobileScreen: React.FC = () => {
|
||||
error={savedError ? 'Failed to load saved stations' : null}
|
||||
onSelectStation={handleSelectStation}
|
||||
onDeleteStation={handleDeleteStation}
|
||||
onOctanePreferenceChange={handleOctanePreferenceChange}
|
||||
octaneUpdatingId={octaneUpdatingId}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
CircularProgress,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { Station, StationSearchRequest } from '../types/stations.types';
|
||||
import { OctanePreference, SavedStation, Station, StationSearchRequest } from '../types/stations.types';
|
||||
import {
|
||||
useStationsSearch,
|
||||
useSavedStations,
|
||||
useSaveStation,
|
||||
useDeleteStation
|
||||
useDeleteStation,
|
||||
useUpdateSavedStation
|
||||
} from '../hooks';
|
||||
import {
|
||||
StationMap,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
StationsSearchForm,
|
||||
GoogleMapsErrorBoundary
|
||||
} from '../components';
|
||||
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -119,12 +121,30 @@ export const StationsPage: React.FC = () => {
|
||||
|
||||
const { mutate: saveStation } = useSaveStation();
|
||||
const { mutate: deleteStation } = useDeleteStation();
|
||||
const { mutate: updateSavedStation } = useUpdateSavedStation();
|
||||
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
|
||||
|
||||
// Create set of saved place IDs for quick lookup
|
||||
const savedPlaceIds = useMemo(
|
||||
() => new Set(savedStations.map((s) => s.placeId)),
|
||||
[savedStations]
|
||||
);
|
||||
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
|
||||
const map = new Map<string, SavedStation>();
|
||||
|
||||
savedStations.forEach((station) => {
|
||||
const placeId = resolveSavedStationPlaceId(station);
|
||||
if (!placeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedStation =
|
||||
station.placeId === placeId ? station : { ...station, placeId };
|
||||
|
||||
map.set(placeId, normalizedStation);
|
||||
});
|
||||
|
||||
return {
|
||||
savedStationsMap: map,
|
||||
savedPlaceIds: new Set(map.keys())
|
||||
};
|
||||
}, [savedStations]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (request: StationSearchRequest) => {
|
||||
@@ -163,6 +183,23 @@ export const StationsPage: React.FC = () => {
|
||||
deleteStation(placeId);
|
||||
};
|
||||
|
||||
const handleOctanePreferenceChange = useCallback(
|
||||
(placeId: string, preference: OctanePreference) => {
|
||||
const flags = octanePreferenceToFlags(preference);
|
||||
setOctaneUpdatingId(placeId);
|
||||
|
||||
updateSavedStation(
|
||||
{ placeId, data: flags },
|
||||
{
|
||||
onSettled: () => {
|
||||
setOctaneUpdatingId((current) => (current === placeId ? null : current));
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[updateSavedStation]
|
||||
);
|
||||
|
||||
// Handle station selection - wrapped in useCallback to prevent infinite renders
|
||||
const handleSelectStation = useCallback((station: Station) => {
|
||||
setMapCenter({
|
||||
@@ -238,6 +275,7 @@ export const StationsPage: React.FC = () => {
|
||||
<StationsList
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
savedStationsMap={savedStationsMap}
|
||||
loading={isSearching}
|
||||
error={searchError ? (searchError as any).message : null}
|
||||
onSaveStation={handleSave}
|
||||
@@ -252,90 +290,118 @@ export const StationsPage: React.FC = () => {
|
||||
error={savedError ? (savedError as any).message : null}
|
||||
onSelectStation={handleSelectStation}
|
||||
onDeleteStation={handleDelete}
|
||||
onOctanePreferenceChange={handleOctanePreferenceChange}
|
||||
octaneUpdatingId={octaneUpdatingId}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout: side-by-side
|
||||
// Desktop layout: top-row map + search, full-width results
|
||||
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' }}>
|
||||
{isMapReady ? (
|
||||
<GoogleMapsErrorBoundary>
|
||||
<StationMap
|
||||
key="desktop-station-map"
|
||||
stations={searchResults}
|
||||
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>
|
||||
|
||||
{/* 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"
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
|
||||
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
|
||||
</Tabs>
|
||||
{isMapReady ? (
|
||||
<GoogleMapsErrorBoundary>
|
||||
<StationMap
|
||||
key="desktop-station-map"
|
||||
stations={searchResults}
|
||||
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>
|
||||
|
||||
{/* 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={handleSelectStation}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<SavedStationsList
|
||||
stations={savedStations}
|
||||
loading={isSavedLoading}
|
||||
error={savedError ? (savedError as any).message : null}
|
||||
onSelectStation={handleSelectStation}
|
||||
onDeleteStation={handleDelete}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</Paper>
|
||||
{/* 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>
|
||||
</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" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', padding: 2 }}>
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<StationsList
|
||||
stations={searchResults}
|
||||
savedPlaceIds={savedPlaceIds}
|
||||
savedStationsMap={savedStationsMap}
|
||||
loading={isSearching}
|
||||
error={searchError ? (searchError as any).message : null}
|
||||
onSaveStation={handleSave}
|
||||
onDeleteStation={handleDelete}
|
||||
onSelectStation={handleSelectStation}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<SavedStationsList
|
||||
stations={savedStations}
|
||||
loading={isSavedLoading}
|
||||
error={savedError ? (savedError as any).message : null}
|
||||
onSelectStation={handleSelectStation}
|
||||
onDeleteStation={handleDelete}
|
||||
onOctanePreferenceChange={handleOctanePreferenceChange}
|
||||
octaneUpdatingId={octaneUpdatingId}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ declare global {
|
||||
*/
|
||||
class InfoWindow {
|
||||
constructor(options?: google.maps.InfoWindowOptions);
|
||||
open(map?: google.maps.Map | null, anchor?: google.maps.Marker): void;
|
||||
open(target?: google.maps.Map | { anchor?: any; map?: google.maps.Map | null; shouldFocus?: boolean } | null, anchor?: google.maps.Marker): void;
|
||||
close(): void;
|
||||
setContent(content: string | HTMLElement): void;
|
||||
}
|
||||
@@ -77,6 +77,27 @@ declare global {
|
||||
BACKWARD_OPEN_ARROW = 'BACKWARD_OPEN_ARROW'
|
||||
}
|
||||
|
||||
namespace marker {
|
||||
interface AdvancedMarkerElementOptions {
|
||||
position?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||
map?: google.maps.Map | null;
|
||||
title?: string;
|
||||
content?: HTMLElement;
|
||||
}
|
||||
|
||||
class AdvancedMarkerElement {
|
||||
constructor(options?: AdvancedMarkerElementOptions);
|
||||
map: google.maps.Map | null;
|
||||
position?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||
content?: HTMLElement;
|
||||
title?: string;
|
||||
addListener(
|
||||
eventName: string,
|
||||
callback: (...args: any[]) => void
|
||||
): google.maps.MapsEventListener;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps Event Listener
|
||||
*/
|
||||
@@ -91,6 +112,7 @@ declare global {
|
||||
zoom?: number;
|
||||
center?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||
mapTypeId?: string;
|
||||
mapId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ export interface SearchLocation {
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface StationSavedMetadata {
|
||||
nickname?: string;
|
||||
notes?: string;
|
||||
isFavorite: boolean;
|
||||
has93Octane: boolean;
|
||||
has93OctaneEthanolFree: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single gas station from search results
|
||||
*/
|
||||
@@ -50,6 +58,10 @@ export interface Station {
|
||||
distance?: number;
|
||||
/** URL to station photo if available */
|
||||
photoUrl?: string;
|
||||
/** Whether the station is saved for the user */
|
||||
isSaved?: boolean;
|
||||
/** Saved-station metadata if applicable */
|
||||
savedMetadata?: StationSavedMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,18 +70,28 @@ export interface Station {
|
||||
export interface SavedStation extends Station {
|
||||
/** Database record ID */
|
||||
id: string;
|
||||
/** Optional saved-station identifier (alias for id when needed) */
|
||||
savedStationId?: string;
|
||||
/** User ID who saved the station */
|
||||
userId: string;
|
||||
/** Stored station id (Google place id) */
|
||||
stationId?: string;
|
||||
/** Custom nickname given by user */
|
||||
nickname?: string;
|
||||
/** User notes about the station */
|
||||
notes?: string;
|
||||
/** Whether station is marked as favorite */
|
||||
isFavorite: boolean;
|
||||
/** Whether the station is confirmed to have 93 octane */
|
||||
has93Octane: boolean;
|
||||
/** Whether the 93 octane is ethanol free */
|
||||
has93OctaneEthanolFree: boolean;
|
||||
/** Created timestamp */
|
||||
createdAt: Date;
|
||||
/** Last updated timestamp */
|
||||
updatedAt: Date;
|
||||
/** Raw station object returned by backend, if any */
|
||||
station?: Station | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +118,10 @@ export interface SaveStationData {
|
||||
notes?: string;
|
||||
/** Whether to mark as favorite */
|
||||
isFavorite?: boolean;
|
||||
/** Whether 93 octane is available */
|
||||
has93Octane?: boolean;
|
||||
/** Whether the 93 octane option is ethanol free */
|
||||
has93OctaneEthanolFree?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,3 +163,6 @@ export interface ApiError {
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** User-facing preference for 93 octane availability */
|
||||
export type OctanePreference = 'none' | 'with_ethanol' | 'ethanol_free';
|
||||
|
||||
@@ -1,58 +1,62 @@
|
||||
/**
|
||||
* @ai-summary Google Maps utility functions
|
||||
* @ai-summary Google Maps utility helpers using AdvancedMarkerElement
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
||||
type AdvancedMarker = google.maps.marker.AdvancedMarkerElement;
|
||||
|
||||
function createMarkerElement(color: string, label?: string): HTMLElement {
|
||||
const marker = document.createElement('div');
|
||||
marker.style.width = '24px';
|
||||
marker.style.height = '24px';
|
||||
marker.style.borderRadius = '50%';
|
||||
marker.style.backgroundColor = color;
|
||||
marker.style.border = '2px solid #ffffff';
|
||||
marker.style.boxShadow = '0 1px 4px rgba(0,0,0,0.4)';
|
||||
marker.style.display = 'flex';
|
||||
marker.style.alignItems = 'center';
|
||||
marker.style.justifyContent = 'center';
|
||||
marker.style.color = '#000';
|
||||
marker.style.fontSize = '12px';
|
||||
marker.style.fontWeight = 'bold';
|
||||
marker.style.lineHeight = '1';
|
||||
marker.style.transform = 'translate(-50%, -50%)';
|
||||
|
||||
if (label) {
|
||||
marker.textContent = label;
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function createStationMarker(
|
||||
station: Station,
|
||||
map: google.maps.Map,
|
||||
isSaved: boolean
|
||||
): google.maps.Marker {
|
||||
): AdvancedMarker {
|
||||
const maps = getGoogleMapsApi();
|
||||
const markerColor = isSaved ? '#FFD700' : '#4285F4'; // Gold for saved, blue for normal
|
||||
const markerColor = isSaved ? '#FFD700' : '#4285F4';
|
||||
const content = createMarkerElement(markerColor, isSaved ? '★' : undefined);
|
||||
|
||||
const marker = new maps.Marker({
|
||||
const marker = new maps.marker.AdvancedMarkerElement({
|
||||
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
|
||||
}
|
||||
content
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -73,11 +77,15 @@ export function createInfoWindow(
|
||||
}
|
||||
${
|
||||
station.rating
|
||||
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(1)}</p>`
|
||||
? `<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;">
|
||||
<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>' : ''}
|
||||
@@ -85,20 +93,12 @@ export function createInfoWindow(
|
||||
</div>
|
||||
`;
|
||||
|
||||
return new maps.InfoWindow({
|
||||
content
|
||||
});
|
||||
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[]
|
||||
markers: AdvancedMarker[]
|
||||
): void {
|
||||
if (markers.length === 0) return;
|
||||
|
||||
@@ -106,55 +106,37 @@ export function fitBoundsToMarkers(
|
||||
const bounds = new maps.LatLngBounds();
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const position = marker.getPosition();
|
||||
if (position) {
|
||||
bounds.extend(position);
|
||||
const positionLiteral = marker.position;
|
||||
if (!positionLiteral) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latLng =
|
||||
positionLiteral instanceof maps.LatLng
|
||||
? positionLiteral
|
||||
: new maps.LatLng(positionLiteral.lat, positionLiteral.lng);
|
||||
bounds.extend(latLng);
|
||||
});
|
||||
|
||||
map.fitBounds(bounds);
|
||||
|
||||
// Add padding
|
||||
const padding = { top: 50, right: 50, bottom: 50, left: 50 };
|
||||
map.fitBounds(bounds, padding);
|
||||
map.fitBounds(bounds, { top: 50, right: 50, bottom: 50, left: 50 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
): AdvancedMarker {
|
||||
const maps = getGoogleMapsApi();
|
||||
const content = createMarkerElement('#FF0000');
|
||||
|
||||
return new maps.Marker({
|
||||
position: {
|
||||
lat: latitude,
|
||||
lng: longitude
|
||||
},
|
||||
return new maps.marker.AdvancedMarkerElement({
|
||||
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
|
||||
}
|
||||
content
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Station to MapMarker
|
||||
*/
|
||||
export function stationToMapMarker(
|
||||
station: Station,
|
||||
isSaved: boolean
|
||||
|
||||
@@ -53,7 +53,7 @@ export function loadGoogleMaps(): Promise<void> {
|
||||
// The callback parameter tells Google Maps to call our function when ready
|
||||
// Using async + callback ensures Google Maps initializes asynchronously
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places&loading=async`;
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places,marker&loading=async`;
|
||||
script.async = true;
|
||||
script.defer = true; // Load asynchronously without blocking parsing (per Maps best practices)
|
||||
|
||||
|
||||
69
frontend/src/features/stations/utils/savedStations.ts
Normal file
69
frontend/src/features/stations/utils/savedStations.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @ai-summary Helper utilities for working with saved stations data
|
||||
*/
|
||||
|
||||
import { OctanePreference, SavedStation, SaveStationData } from '../types/stations.types';
|
||||
|
||||
export function resolveSavedStationPlaceId(station: SavedStation): string | undefined {
|
||||
return station.placeId || station.station?.placeId || station.stationId;
|
||||
}
|
||||
|
||||
export function resolveSavedStationName(station: SavedStation): string {
|
||||
return (
|
||||
station.nickname ||
|
||||
station.name ||
|
||||
station.station?.name ||
|
||||
'Saved Station'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSavedStationAddress(station: SavedStation): string {
|
||||
return station.address || station.station?.address || '';
|
||||
}
|
||||
|
||||
export function resolveSavedStationCoordinates(
|
||||
station: SavedStation
|
||||
): { latitude: number; longitude: number } | undefined {
|
||||
const lat = station.latitude ?? station.station?.latitude;
|
||||
const lng = station.longitude ?? station.station?.longitude;
|
||||
|
||||
if (lat === undefined || lng === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { latitude: lat, longitude: lng };
|
||||
}
|
||||
|
||||
export function getOctanePreferenceFromFlags(
|
||||
has93Octane: boolean,
|
||||
has93OctaneEthanolFree: boolean
|
||||
): OctanePreference {
|
||||
if (!has93Octane) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return has93OctaneEthanolFree ? 'ethanol_free' : 'with_ethanol';
|
||||
}
|
||||
|
||||
export function octanePreferenceToFlags(
|
||||
preference: OctanePreference
|
||||
): Pick<SaveStationData, 'has93Octane' | 'has93OctaneEthanolFree'> {
|
||||
if (preference === 'none') {
|
||||
return {
|
||||
has93Octane: false,
|
||||
has93OctaneEthanolFree: false
|
||||
};
|
||||
}
|
||||
|
||||
if (preference === 'ethanol_free') {
|
||||
return {
|
||||
has93Octane: true,
|
||||
has93OctaneEthanolFree: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
has93Octane: true,
|
||||
has93OctaneEthanolFree: false
|
||||
};
|
||||
}
|
||||
@@ -67,6 +67,15 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
initialData,
|
||||
loading,
|
||||
}) => {
|
||||
const formatVehicleLabel = (value?: string): string => {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
const [makes, setMakes] = useState<DropdownOption[]>([]);
|
||||
const [models, setModels] = useState<DropdownOption[]>([]);
|
||||
@@ -338,7 +347,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<option value="">Select Make</option>
|
||||
{makes.map((make) => (
|
||||
<option key={make.id} value={make.name}>
|
||||
{make.name}
|
||||
{formatVehicleLabel(make.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -357,7 +366,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<option value="">Select Model</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.name}>
|
||||
{model.name}
|
||||
{formatVehicleLabel(model.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user