diff --git a/STATION-CHANGES.md b/STATION-CHANGES.md
index a2996a8..86ce21f 100644
--- a/STATION-CHANGES.md
+++ b/STATION-CHANGES.md
@@ -67,21 +67,6 @@ Tradeoffs:
- Slightly more frontend code, but minimal security risk.
- Must ensure caching behavior is acceptable (browser cache won’t cache `blob:` URLs; rely on backend caching headers + client-side memoization).
-#### Option A2 (Simplest Code, Higher Risk): Make Photo Endpoint Public
-
-Why: Restores `
` behavior with minimal frontend work.
-
-Implementation outline:
-- Backend: remove `preHandler: [fastify.authenticate]` from `/stations/photo/:reference`.
-- Add lightweight protections to reduce abuse (choose as many as feasible without adding heavy deps):
- - strict input validation (length/charset) for `reference`
- - low maxWidth clamp and no arbitrary URL fetching
- - maintain `Cache-Control` header (already present)
- - optionally add server-side rate limit (only if repo already uses a rate-limit plugin; avoid introducing new infra unless necessary)
-
-Tradeoffs:
-- Anyone can hit `/api/stations/photo/:reference` and spend your Google quota.
-
### Option B (Remove Images): Simplify Cards
Why: If image delivery adds too much complexity or risk, remove images from station cards.
@@ -124,7 +109,7 @@ Important: some saved stations may have `latitude/longitude = 0` if cache miss;
- Desktop saved list: add a “Navigate” icon button that opens a small menu with the 3 links (cleaner than inline links inside `ListItemText`).
- File: `frontend/src/features/stations/components/SavedStationsList.tsx`
-- Mobile bottom sheet (station details): add a “Navigate” section with the same 3 links (buttons or list items).
+- Mobile bottom sheet (station details): add a “Navigate” section with the same 3 links as buttons.
- File: `frontend/src/features/stations/mobile/StationsMobileScreen.tsx`
## Work Breakdown for Multiple Agents
diff --git a/frontend/src/features/stations/__tests__/components/StationCard.test.tsx b/frontend/src/features/stations/__tests__/components/StationCard.test.tsx
index 2340f48..9dc35d6 100644
--- a/frontend/src/features/stations/__tests__/components/StationCard.test.tsx
+++ b/frontend/src/features/stations/__tests__/components/StationCard.test.tsx
@@ -8,6 +8,12 @@ import '@testing-library/jest-dom';
import { StationCard } from '../../components/StationCard';
import { Station } from '../../types/stations.types';
+jest.mock('@/core/api/client', () => ({
+ apiClient: {
+ get: jest.fn(() => Promise.resolve({ data: new Blob(['photo'], { type: 'image/jpeg' }) }))
+ }
+}));
+
const mockStation: Station = {
placeId: 'test-place-id',
name: 'Shell Gas Station',
@@ -23,6 +29,14 @@ describe('StationCard', () => {
beforeEach(() => {
jest.clearAllMocks();
window.open = jest.fn();
+
+ // JSDOM may not implement these; mock for blob URL handling
+ if (!global.URL.createObjectURL) {
+ global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
+ }
+ if (!global.URL.revokeObjectURL) {
+ global.URL.revokeObjectURL = jest.fn();
+ }
});
describe('Rendering', () => {
@@ -33,12 +47,12 @@ describe('StationCard', () => {
expect(screen.getByText('123 Main St, San Francisco, CA 94105')).toBeInTheDocument();
});
- it('should render station photo if available', () => {
+ it('should render station photo if available', async () => {
render();
- const photo = screen.getByAltText('Shell Gas Station');
+ const photo = await screen.findByAltText('Shell Gas Station');
expect(photo).toBeInTheDocument();
- expect(photo).toHaveAttribute('src', '/api/stations/photo/mock-photo-reference');
+ expect(photo).toHaveAttribute('src', expect.stringContaining('blob:'));
});
it('should render rating when available', () => {
diff --git a/frontend/src/features/stations/__tests__/utils/navigation-links.test.ts b/frontend/src/features/stations/__tests__/utils/navigation-links.test.ts
new file mode 100644
index 0000000..e3a5423
--- /dev/null
+++ b/frontend/src/features/stations/__tests__/utils/navigation-links.test.ts
@@ -0,0 +1,34 @@
+import { buildNavigationLinks } from '../../utils/navigation-links';
+import { Station } from '../../types/stations.types';
+
+const baseStation: Station = {
+ placeId: 'place-123',
+ name: 'Test Station',
+ address: '123 Main St, City',
+ latitude: 37.7749,
+ longitude: -122.4194,
+ rating: 4.2
+};
+
+describe('buildNavigationLinks', () => {
+ it('uses coordinates when valid', () => {
+ const links = buildNavigationLinks(baseStation);
+
+ expect(links.google).toContain('destination=37.7749,-122.4194');
+ expect(links.google).toContain('destination_place_id=place-123');
+ expect(links.apple).toContain('daddr=37.7749,-122.4194');
+ expect(links.waze).toContain('ll=37.7749,-122.4194');
+ });
+
+ it('falls back to query when coordinates are missing', () => {
+ const links = buildNavigationLinks({
+ ...baseStation,
+ latitude: 0,
+ longitude: 0
+ });
+
+ expect(links.google).toContain('query=');
+ expect(links.apple).toContain('q=');
+ expect(links.waze).toContain('q=');
+ });
+});
diff --git a/frontend/src/features/stations/components/SavedStationsList.tsx b/frontend/src/features/stations/components/SavedStationsList.tsx
index 08252ac..21c8d6d 100644
--- a/frontend/src/features/stations/components/SavedStationsList.tsx
+++ b/frontend/src/features/stations/components/SavedStationsList.tsx
@@ -8,16 +8,18 @@ import {
ListItem,
ListItemButton,
ListItemText,
- ListItemSecondaryAction,
- IconButton,
Box,
Typography,
Chip,
Divider,
Alert,
- Skeleton
+ Skeleton,
+ Menu,
+ MenuItem,
+ Button
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
+import NavigationIcon from '@mui/icons-material/Navigation';
import { OctanePreference, SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance';
import {
@@ -27,6 +29,7 @@ import {
resolveSavedStationPlaceId
} from '../utils/savedStations';
import { OctanePreferenceSelector } from './OctanePreferenceSelector';
+import { buildNavigationLinks } from '../utils/navigation-links';
interface SavedStationsListProps {
stations: SavedStation[];
@@ -47,6 +50,66 @@ export const SavedStationsList: React.FC = ({
onOctanePreferenceChange,
octaneUpdatingId
}) => {
+ const [navAnchorEl, setNavAnchorEl] = React.useState(null);
+ const [navStation, setNavStation] = React.useState(null);
+
+ const handleOpenNavMenu = (event: React.MouseEvent, station: SavedStation) => {
+ event.stopPropagation();
+ setNavAnchorEl(event.currentTarget);
+ setNavStation(station);
+ };
+
+ const handleCloseNavMenu = () => {
+ setNavAnchorEl(null);
+ setNavStation(null);
+ };
+
+ const renderNavMenu = () => {
+ if (!navStation) {
+ return null;
+ }
+
+ const links = buildNavigationLinks(navStation);
+
+ return (
+
+ );
+ };
+
if (loading) {
return (
@@ -168,39 +231,53 @@ export const SavedStationsList: React.FC = ({
value={octanePreference}
onChange={(value) => onOctanePreferenceChange?.(placeId, value)}
disabled={!onOctanePreferenceChange || octaneUpdatingId === placeId}
- helperText="Show on search cards"
/>
)}
+
+ }
+ onClick={(e) => handleOpenNavMenu(e, station)}
+ sx={{ minHeight: 36 }}
+ >
+ Navigate
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ if (placeId) {
+ onDeleteStation?.(placeId);
+ }
+ }}
+ disabled={!placeId}
+ sx={{ minHeight: 36 }}
+ >
+ Delete
+
+
}
/>
-
- {
- e.stopPropagation();
- if (placeId) {
- onDeleteStation?.(placeId);
- }
- }}
- title="Delete saved station"
- sx={{
- minWidth: '44px',
- minHeight: '44px'
- }}
- disabled={!placeId}
- >
-
-
-
{index < stations.length - 1 && }
);
})}
+ {renderNavMenu()}
);
};
diff --git a/frontend/src/features/stations/components/StationCard.tsx b/frontend/src/features/stations/components/StationCard.tsx
index efd7776..cb95c54 100644
--- a/frontend/src/features/stations/components/StationCard.tsx
+++ b/frontend/src/features/stations/components/StationCard.tsx
@@ -6,7 +6,6 @@ import React from 'react';
import {
Card,
CardContent,
- CardMedia,
Typography,
Chip,
IconButton,
@@ -18,7 +17,7 @@ import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import DirectionsIcon from '@mui/icons-material/Directions';
import { Station, SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance';
-import { getStationPhotoUrl } from '../utils/photo-utils';
+import { StationPhoto } from './StationPhoto';
interface StationCardProps {
station: Station;
@@ -84,15 +83,7 @@ export const StationCard: React.FC = ({
flexDirection: 'column'
}}
>
- {station.photoReference && (
-
- )}
+
{/* Station Name */}
diff --git a/frontend/src/features/stations/components/StationPhoto.tsx b/frontend/src/features/stations/components/StationPhoto.tsx
new file mode 100644
index 0000000..15cbf27
--- /dev/null
+++ b/frontend/src/features/stations/components/StationPhoto.tsx
@@ -0,0 +1,105 @@
+/**
+ * @ai-summary Authenticated station photo loader using backend proxy
+ */
+
+import React, { useEffect, useState } from 'react';
+import { CardMedia, CircularProgress, Box } from '@mui/material';
+import { apiClient } from '@/core/api/client';
+import { getStationPhotoUrl } from '../utils/photo-utils';
+
+interface StationPhotoProps {
+ photoReference?: string;
+ alt: string;
+ height?: number | string;
+}
+
+export const StationPhoto: React.FC = ({ photoReference, alt, height = 200 }) => {
+ const [photoUrl, setPhotoUrl] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ let isMounted = true;
+ let objectUrl: string | null = null;
+
+ const loadPhoto = async () => {
+ if (!photoReference) {
+ setPhotoUrl(null);
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const url = getStationPhotoUrl(photoReference);
+ if (!url) {
+ setPhotoUrl(null);
+ return;
+ }
+
+ const response = await apiClient.get(url, {
+ responseType: 'blob'
+ });
+
+ objectUrl = URL.createObjectURL(response.data);
+
+ if (isMounted) {
+ setPhotoUrl(objectUrl);
+ }
+ } catch (error) {
+ // Silent fail – just hide the image if the request is unauthorized or fails
+ if (isMounted) {
+ setPhotoUrl(null);
+ }
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ loadPhoto();
+
+ return () => {
+ isMounted = false;
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ };
+ }, [photoReference]);
+
+ if (!photoReference) {
+ return null;
+ }
+
+ if (isLoading && !photoUrl) {
+ return (
+
+
+
+ );
+ }
+
+ if (!photoUrl) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default StationPhoto;
diff --git a/frontend/src/features/stations/components/index.ts b/frontend/src/features/stations/components/index.ts
index d3c15cd..e4b62a6 100644
--- a/frontend/src/features/stations/components/index.ts
+++ b/frontend/src/features/stations/components/index.ts
@@ -3,6 +3,7 @@
*/
export { StationCard } from './StationCard';
+export { StationPhoto } from './StationPhoto';
export { StationsList } from './StationsList';
export { SavedStationsList } from './SavedStationsList';
export { StationsSearchForm } from './StationsSearchForm';
diff --git a/frontend/src/features/stations/mobile/StationsMobileScreen.tsx b/frontend/src/features/stations/mobile/StationsMobileScreen.tsx
index a6e84b3..5d7cd79 100644
--- a/frontend/src/features/stations/mobile/StationsMobileScreen.tsx
+++ b/frontend/src/features/stations/mobile/StationsMobileScreen.tsx
@@ -13,7 +13,8 @@ import {
IconButton,
Typography,
Divider,
- useTheme
+ useTheme,
+ Button
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import BookmarkIcon from '@mui/icons-material/Bookmark';
@@ -41,6 +42,7 @@ import {
OctanePreference
} from '../types/stations.types';
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
+import { buildNavigationLinks } from '../utils/navigation-links';
// Tab indices
const TAB_SEARCH = 0;
@@ -178,6 +180,11 @@ export const StationsMobileScreen: React.FC = () => {
// TODO: Implement pull-to-refresh
}, []);
+ const navigationLinks = useMemo(
+ () => (selectedStation ? buildNavigationLinks(selectedStation) : null),
+ [selectedStation]
+ );
+
return (
{
)}
+
+ {navigationLinks && (
+
+
+ Navigate
+
+
+
+
+
+
+
+ )}
)}
diff --git a/frontend/src/features/stations/utils/navigation-links.ts b/frontend/src/features/stations/utils/navigation-links.ts
new file mode 100644
index 0000000..100fada
--- /dev/null
+++ b/frontend/src/features/stations/utils/navigation-links.ts
@@ -0,0 +1,61 @@
+/**
+ * @ai-summary Helpers to build navigation URLs for stations
+ */
+
+import { SavedStation, Station } from '../types/stations.types';
+
+type StationLike = Pick & Partial;
+
+const hasValidCoordinates = (station: StationLike): boolean => {
+ const { latitude, longitude } = station;
+ if (typeof latitude !== 'number' || typeof longitude !== 'number') {
+ return false;
+ }
+
+ if (Number.isNaN(latitude) || Number.isNaN(longitude)) {
+ return false;
+ }
+
+ if (latitude === 0 && longitude === 0) {
+ return false;
+ }
+
+ return true;
+};
+
+const getQuery = (station: StationLike): string => {
+ return encodeURIComponent(station.address || station.name);
+};
+
+export interface NavigationLinks {
+ google: string;
+ apple: string;
+ waze: string;
+}
+
+/**
+ * Build navigation URLs for Google Maps, Apple Maps, and Waze.
+ * Uses coordinates when available; falls back to address/name query.
+ */
+export const buildNavigationLinks = (station: StationLike): NavigationLinks => {
+ if (hasValidCoordinates(station)) {
+ const { latitude, longitude, placeId } = station;
+ const latLng = `${latitude},${longitude}`;
+
+ return {
+ google: `https://www.google.com/maps/dir/?api=1&destination=${latLng}${
+ placeId ? `&destination_place_id=${placeId}` : ''
+ }`,
+ apple: `https://maps.apple.com/?daddr=${latLng}`,
+ waze: `https://waze.com/ul?ll=${latLng}&navigate=yes`
+ };
+ }
+
+ const query = getQuery(station);
+
+ return {
+ google: `https://www.google.com/maps/search/?api=1&query=${query}`,
+ apple: `https://maps.apple.com/?q=${query}`,
+ waze: `https://waze.com/ul?q=${query}&navigate=yes`
+ };
+};