Gas Station Feature

This commit is contained in:
Eric Gullickson
2025-11-04 18:46:46 -06:00
parent d8d0ada83f
commit 5dc58d73b9
61 changed files with 12952 additions and 52 deletions

View File

@@ -35,12 +35,19 @@ FROM nginx:alpine AS production
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nginx
# Copy built assets from build stage
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy and prepare config loader script
COPY scripts/load-config.sh /app/load-config.sh
RUN chmod +x /app/load-config.sh
# Set environment variable for secrets directory
ENV SECRETS_DIR=/run/secrets
# Set up proper permissions for nginx with non-root user
RUN chown -R nodejs:nginx /usr/share/nginx/html && \
chown -R nodejs:nginx /var/cache/nginx && \
@@ -48,7 +55,8 @@ RUN chown -R nodejs:nginx /usr/share/nginx/html && \
chown -R nodejs:nginx /etc/nginx/conf.d && \
chown nodejs:nginx /etc/nginx/nginx.conf && \
touch /var/run/nginx.pid && \
chown -R nodejs:nginx /var/run/nginx.pid
chown -R nodejs:nginx /var/run/nginx.pid && \
chown nodejs:nginx /app/load-config.sh
# Switch to non-root user
USER nodejs
@@ -60,5 +68,5 @@ EXPOSE 3000 3443
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
# Start: load config then start nginx
CMD ["sh", "-c", "/app/load-config.sh && nginx -g 'daemon off;'"]

View File

@@ -0,0 +1,351 @@
/**
* @ai-summary End-to-end tests for Gas Stations feature
*
* Prerequisites:
* - Backend API running
* - Test user authenticated
* - Google Maps API key configured
*
* Run with: npm run e2e
*/
describe('Gas Stations Feature', () => {
beforeEach(() => {
// Login as test user
cy.login();
cy.visit('/stations');
});
describe('Search for Nearby Stations', () => {
it('should allow searching with current location', () => {
// Mock geolocation
cy.window().then((win) => {
cy.stub(win.navigator.geolocation, 'getCurrentPosition').callsFake((successCallback) => {
successCallback({
coords: {
latitude: 37.7749,
longitude: -122.4194,
accuracy: 10
}
} as GeolocationPosition);
});
});
// Click current location button
cy.contains('button', 'Current Location').click();
// Click search
cy.contains('button', 'Search').click();
// Verify results displayed
cy.get('[data-testid="station-card"]').should('have.length.greaterThan', 0);
cy.contains('Shell').or('Chevron').or('76').or('Exxon').should('be.visible');
});
it('should allow searching with manual coordinates', () => {
// Enter manual coordinates
cy.get('input[name="latitude"]').clear().type('37.7749');
cy.get('input[name="longitude"]').clear().type('-122.4194');
// Adjust radius
cy.get('[data-testid="radius-slider"]').click();
// Search
cy.contains('button', 'Search').click();
// Verify results
cy.get('[data-testid="station-card"]').should('exist');
});
it('should handle search errors gracefully', () => {
// Enter invalid coordinates
cy.get('input[name="latitude"]').clear().type('999');
cy.get('input[name="longitude"]').clear().type('999');
// Search
cy.contains('button', 'Search').click();
// Verify error message
cy.contains('error', { matchCase: false }).should('be.visible');
});
it('should display loading state during search', () => {
cy.intercept('POST', '/api/stations/search', {
delay: 1000,
body: { stations: [] }
});
cy.contains('button', 'Search').click();
// Verify loading indicator
cy.get('[data-testid="loading-skeleton"]').should('be.visible');
});
});
describe('View Stations on Map', () => {
beforeEach(() => {
// Perform a search first
cy.get('input[name="latitude"]').clear().type('37.7749');
cy.get('input[name="longitude"]').clear().type('-122.4194');
cy.contains('button', 'Search').click();
cy.wait(2000);
});
it('should display map with station markers', () => {
// Verify map is loaded
cy.get('[data-testid="station-map"]').should('be.visible');
// Verify markers present (if Google Maps loaded)
cy.get('.gm-style').should('exist');
});
it('should show info window when marker clicked', () => {
// This test assumes Google Maps is loaded
// Click first marker (may need custom data-testid on markers)
cy.get('[data-testid="map-marker"]').first().click();
// Verify info window content
cy.contains('Get Directions').should('be.visible');
});
it('should zoom to fit all markers', () => {
// Verify map auto-fits to show all markers
cy.get('[data-testid="station-map"]').should('be.visible');
// Check that multiple markers are in view
cy.get('[data-testid="station-card"]').then(($cards) => {
expect($cards.length).to.be.greaterThan(1);
});
});
});
describe('Save Station to Favorites', () => {
beforeEach(() => {
// Search first
cy.get('input[name="latitude"]').clear().type('37.7749');
cy.get('input[name="longitude"]').clear().type('-122.4194');
cy.contains('button', 'Search').click();
cy.wait(1000);
});
it('should save a station to favorites', () => {
// Find first station card
cy.get('[data-testid="station-card"]').first().within(() => {
// Click bookmark button
cy.get('button[title*="favorites"]').click();
});
// Verify optimistic UI update (bookmark filled)
cy.get('[data-testid="station-card"]').first().within(() => {
cy.get('button[title*="Remove"]').should('exist');
});
// Navigate to Saved tab
cy.contains('Saved Stations').click();
// Verify station appears in saved list
cy.get('[data-testid="saved-station-item"]').should('have.length.greaterThan', 0);
});
it('should allow adding nickname and notes', () => {
// Open station details/save modal (if exists)
cy.get('[data-testid="station-card"]').first().click();
// Enter nickname
cy.get('input[name="nickname"]').type('Work Gas Station');
cy.get('textarea[name="notes"]').type('Best prices in area');
// Save
cy.contains('button', 'Save').click();
// Verify saved
cy.contains('Work Gas Station').should('be.visible');
});
it('should prevent duplicate saves', () => {
// Save station
cy.get('[data-testid="station-card"]').first().within(() => {
cy.get('button[title*="favorites"]').click();
});
// Try to save again (should toggle off)
cy.get('[data-testid="station-card"]').first().within(() => {
cy.get('button[title*="Remove"]').click();
});
// Verify removed
cy.get('[data-testid="station-card"]').first().within(() => {
cy.get('button[title*="Add"]').should('exist');
});
});
});
describe('View Saved Stations List', () => {
beforeEach(() => {
// Navigate to saved tab
cy.contains('Saved Stations').click();
});
it('should display all saved stations', () => {
cy.get('[data-testid="saved-station-item"]').should('exist');
});
it('should show empty state when no saved stations', () => {
// If no stations saved
cy.get('body').then(($body) => {
if ($body.find('[data-testid="saved-station-item"]').length === 0) {
cy.contains('No saved stations').should('be.visible');
}
});
});
it('should display custom nicknames', () => {
// Verify stations show nicknames if set
cy.get('[data-testid="saved-station-item"]').first().should('contain.text', '');
});
});
describe('Delete Saved Station', () => {
beforeEach(() => {
// Ensure at least one station is saved
cy.visit('/stations');
cy.get('input[name="latitude"]').clear().type('37.7749');
cy.get('input[name="longitude"]').clear().type('-122.4194');
cy.contains('button', 'Search').click();
cy.wait(1000);
cy.get('[data-testid="station-card"]').first().within(() => {
cy.get('button[title*="favorites"]').click();
});
cy.wait(500);
cy.contains('Saved Stations').click();
});
it('should delete a saved station', () => {
// Count initial stations
cy.get('[data-testid="saved-station-item"]').its('length').then((initialCount) => {
// Delete first station
cy.get('[data-testid="saved-station-item"]').first().within(() => {
cy.get('button[title*="delete"]').or('button[title*="remove"]').click();
});
// Verify count decreased
cy.get('[data-testid="saved-station-item"]').should('have.length', initialCount - 1);
});
});
it('should show optimistic removal', () => {
// Get station name
cy.get('[data-testid="saved-station-item"]').first().invoke('text').then((stationName) => {
// Delete
cy.get('[data-testid="saved-station-item"]').first().within(() => {
cy.get('button[title*="delete"]').click();
});
// Verify immediately removed from UI
cy.contains(stationName).should('not.exist');
});
});
it('should handle delete errors', () => {
// Mock API error
cy.intercept('DELETE', '/api/stations/saved/*', {
statusCode: 500,
body: { error: 'Server error' }
});
// Try to delete
cy.get('[data-testid="saved-station-item"]').first().within(() => {
cy.get('button[title*="delete"]').click();
});
// Verify error message or rollback
cy.contains('error', { matchCase: false }).should('be.visible');
});
});
describe('Mobile Navigation Flow', () => {
beforeEach(() => {
cy.viewport('iphone-x');
cy.visit('/m/stations');
});
it('should navigate between tabs', () => {
// Verify Search tab active
cy.contains('Search').should('have.class', 'Mui-selected').or('have.attr', 'aria-selected', 'true');
// Click Saved tab
cy.contains('Saved').click();
cy.contains('Saved').should('have.class', 'Mui-selected');
// Click Map tab
cy.contains('Map').click();
cy.contains('Map').should('have.class', 'Mui-selected');
});
it('should display mobile-optimized layout', () => {
// Verify bottom navigation present
cy.get('[role="tablist"]').should('be.visible');
// Verify touch targets are 44px minimum
cy.get('button').first().should('have.css', 'min-height').and('match', /44/);
});
});
describe('Error Recovery', () => {
it('should recover from network errors', () => {
// Mock network failure
cy.intercept('POST', '/api/stations/search', {
forceNetworkError: true
});
// Try to search
cy.contains('button', 'Search').click();
// Verify error displayed
cy.contains('error', { matchCase: false }).or('network').should('be.visible');
// Retry button should be present
cy.contains('button', 'Retry').click();
});
it('should handle authentication errors', () => {
// Mock 401
cy.intercept('GET', '/api/stations/saved', {
statusCode: 401,
body: { error: 'Unauthorized' }
});
cy.visit('/stations');
// Should redirect to login or show auth error
cy.url().should('include', '/login').or('contain', '/auth');
});
});
describe('Integration with Fuel Logs', () => {
it('should allow selecting station when creating fuel log', () => {
// Navigate to fuel logs
cy.visit('/fuel-logs/new');
// Open station picker
cy.get('input[name="station"]').or('[data-testid="station-picker"]').click();
// Select a saved station
cy.contains('Work Station').or('Shell').click();
// Verify selection
cy.get('input[name="station"]').should('have.value', '');
});
});
});
/**
* Custom Cypress commands for stations feature
*/
declare global {
namespace Cypress {
interface Chainable {
login(): Chainable<void>;
}
}
}

View File

@@ -0,0 +1,342 @@
# Runtime Configuration Pattern
## Overview
MotoVaultPro uses a **K8s-aligned runtime configuration pattern** where sensitive values (like API keys) are loaded at container startup from mounted secrets, not at build time.
This approach:
- Mirrors Kubernetes deployment patterns
- Allows configuration changes without rebuilding images
- Keeps secrets out of build artifacts and environment variables
- Enables easy secret rotation in production
## Architecture
### How It Works
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`
- Generates `/usr/share/nginx/html/config.js` with runtime values
- Starts nginx
3. **App Load Time**:
- `index.html` loads `<script src="/config.js"></script>`
- `window.CONFIG` is available before React initializes
- React app reads configuration via `getConfig()` hook
### File Structure
```
frontend/
├── scripts/
│ └── load-config.sh # Reads secrets, generates config.js
├── src/
│ └── core/config/
│ └── config.types.ts # TypeScript types and helpers
├── index.html # Loads config.js before app
└── Dockerfile # Runs load-config.sh before nginx
```
## Usage in Components
### Reading Configuration
```typescript
import { getConfig, getGoogleMapsApiKey } from '@/core/config/config.types';
export function MyComponent() {
// Get full config object with error handling
const config = getConfig();
// Or get specific value with fallback
const apiKey = getGoogleMapsApiKey();
return <MapComponent apiKey={apiKey} />;
}
```
### Conditional Features
```typescript
import { isConfigLoaded } from '@/core/config/config.types';
export function FeatureGate() {
if (!isConfigLoaded()) {
return <LoadingFallback />;
}
return <AdvancedFeature />;
}
```
## Adding New Runtime Configuration Values
### 1. Update load-config.sh
```bash
# In frontend/scripts/load-config.sh
if [ -f "$SECRETS_DIR/new-api-key" ]; then
NEW_API_KEY=$(cat "$SECRETS_DIR/new-api-key")
echo "[Config] Loaded New API Key"
else
NEW_API_KEY=""
fi
# In the config.js generation:
cat > "$CONFIG_FILE" <<EOF
window.CONFIG = {
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
newApiKey: '$NEW_API_KEY'
};
EOF
```
### 2. Update config.types.ts
```typescript
export interface AppConfig {
googleMapsApiKey: string;
newApiKey: string; // Add new field
}
export function getNewApiKey(): string {
try {
const config = getConfig();
return config.newApiKey || '';
} catch {
console.warn('New API Key not available.');
return '';
}
}
```
### 3. Update docker-compose.yml
```yaml
mvp-frontend:
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/new-api-key.txt:/run/secrets/new-api-key:ro # Add new secret
```
### 4. Create Secret File
```bash
mkdir -p ./secrets/app
echo "your-api-key-value" > ./secrets/app/new-api-key.txt
```
## Docker-Compose Configuration
The frontend service mounts secrets from the host filesystem:
```yaml
mvp-frontend:
environment:
SECRETS_DIR: /run/secrets
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
```
- `:ro` flag makes secrets read-only
- Secrets are available at container startup
- Changes require container restart (no image rebuild)
## Kubernetes Deployment
When deploying to Kubernetes, update the deployment manifest:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: motovaultpro-frontend
spec:
template:
spec:
containers:
- name: frontend
image: motovaultpro-frontend:latest
env:
- name: SECRETS_DIR
value: /run/secrets
volumeMounts:
- name: google-maps-key
mountPath: /run/secrets/google-maps-api-key
subPath: google-maps-api-key
readOnly: true
volumes:
- name: google-maps-key
secret:
secretName: google-maps-api-key
items:
- key: api-key
path: google-maps-api-key
```
## Development Setup
### Local Development
For `npm run dev` (Vite dev server):
```bash
# Copy secrets to secrets directory
mkdir -p ./secrets/app
echo "your-test-api-key" > ./secrets/app/google-maps-api-key.txt
# Set environment variable
export SECRETS_DIR=./secrets/app
# Start Vite dev server
npm run dev
```
To access config in your app:
```typescript
// In development, config.js may not exist
// Use graceful fallback:
export function useConfig() {
const [config, setConfig] = useState<AppConfig | null>(null);
useEffect(() => {
if (window.CONFIG) {
setConfig(window.CONFIG);
} else {
// Fallback for dev without config.js
setConfig({
googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_KEY || '',
});
}
}, []);
return config;
}
```
### Container-Based Testing
Recommended approach (per CLAUDE.md):
```bash
# Ensure secrets exist
mkdir -p ./secrets/app
echo "your-api-key" > ./secrets/app/google-maps-api-key.txt
# Rebuild and start containers
make rebuild
make logs
# Verify config.js was generated
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
# Should output:
# window.CONFIG = {
# googleMapsApiKey: 'your-api-key'
# };
```
## Debugging
### Verify Secrets are Mounted
```bash
docker compose exec mvp-frontend cat /run/secrets/google-maps-api-key
```
### Check Generated config.js
```bash
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
```
### View Container Logs
```bash
docker compose logs mvp-frontend
```
Look for lines like:
```
[Config] Loaded Google Maps API key from /run/secrets/google-maps-api-key
[Config] Generated /usr/share/nginx/html/config.js
```
### Browser Console
Check browser console for any config loading errors:
```javascript
console.log(window.CONFIG);
// Should show: { googleMapsApiKey: "your-key" }
```
## Best Practices
1. **Never Log Secrets**: The load-config.sh script only logs that a key was loaded, never the actual value
2. **Always Validate**: Use `getConfig()` which throws errors if config is missing:
```typescript
try {
const config = getConfig();
} catch (error) {
// Handle missing config gracefully
}
```
3. **Use Fallbacks**: For optional features, use graceful fallbacks:
```typescript
const apiKey = getGoogleMapsApiKey(); // Returns empty string if not available
```
4. **Documentation**: Update this file when adding new configuration values
5. **Testing**: Test with and without secrets in containers
## Security Considerations
- Secrets are mounted as files, not environment variables
- Files are read-only (`:ro` flag)
- config.js is generated at startup, not included in image
- Browser console can see config values (like any JavaScript)
- For highly sensitive values, consider additional encryption
## Troubleshooting
### config.js not generated
**Symptom**: Browser shows `window.CONFIG is undefined`
**Solutions**:
1. Check secret file exists: `docker compose exec mvp-frontend cat /run/secrets/google-maps-api-key`
2. Check load-config.sh runs: `docker compose logs mvp-frontend`
3. Verify permissions: `docker compose exec mvp-frontend ls -la /run/secrets/`
### Container fails to start
**Symptom**: Container crashes during startup
**Solution**:
1. Check logs: `docker compose logs mvp-frontend`
2. Verify script has execute permissions (in Dockerfile)
3. Test script locally: `sh frontend/scripts/load-config.sh`
### Secret changes not reflected
**Symptom**: Container still uses old secret after file change
**Solution**:
```bash
# Restart container to reload secrets
docker compose restart mvp-frontend
# Or fully rebuild
make rebuild
```
## References
- [Kubernetes Secrets Documentation](https://kubernetes.io/docs/concepts/configuration/secret/)
- [Docker Secrets](https://docs.docker.com/engine/swarm/secrets/)
- [12Factor Config](https://12factor.net/config)

View File

@@ -8,6 +8,8 @@
</head>
<body>
<div id="root"></div>
<!-- Load runtime config before app initializes -->
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
frontend/scripts/load-config.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/sh
# Load runtime configuration from secrets and generate config.js
# This script is called at container startup before nginx starts
set -e
SECRETS_DIR="${SECRETS_DIR:-/run/secrets}"
CONFIG_FILE="/usr/share/nginx/html/config.js"
GOOGLE_MAPS_API_KEY=""
# Try to read Google Maps API key from secret file
if [ -f "$SECRETS_DIR/google-maps-api-key" ]; then
GOOGLE_MAPS_API_KEY=$(cat "$SECRETS_DIR/google-maps-api-key")
echo "[Config] Loaded Google Maps API key from $SECRETS_DIR/google-maps-api-key"
else
echo "[Config] Warning: Google Maps API key not found at $SECRETS_DIR/google-maps-api-key"
GOOGLE_MAPS_API_KEY=""
fi
# Generate config.js
cat > "$CONFIG_FILE" <<EOF
window.CONFIG = {
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY'
};
EOF
echo "[Config] Generated $CONFIG_FILE"
echo "[Config] Config contents:"
cat "$CONFIG_FILE"

View File

@@ -26,6 +26,8 @@ const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage'
const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPage').then(m => ({ default: m.DocumentsPage })));
const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage })));
const MaintenancePage = lazy(() => import('./features/maintenance/pages/MaintenancePage').then(m => ({ default: m.MaintenancePage })));
const StationsPage = lazy(() => import('./features/stations/pages/StationsPage').then(m => ({ default: m.StationsPage })));
const StationsMobileScreen = lazy(() => import('./features/stations/mobile/StationsMobileScreen').then(m => ({ default: m.default })));
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
@@ -148,7 +150,7 @@ const LogFuelScreen: React.FC = () => {
return (
<div className="space-y-4">
<MobileErrorBoundary screenName="FuelLogForm" key="fuel-form">
<MobileErrorBoundary screenName="FuelLogForm">
<FuelLogForm onSuccess={() => {
// Refresh dependent data
try {
@@ -163,7 +165,7 @@ const LogFuelScreen: React.FC = () => {
}
}} />
</MobileErrorBoundary>
<MobileErrorBoundary screenName="FuelLogsSection" key="fuel-section">
<MobileErrorBoundary screenName="FuelLogsSection">
<GlassCard>
<div className="py-2">
{isLoading ? (
@@ -181,7 +183,7 @@ const LogFuelScreen: React.FC = () => {
</GlassCard>
</MobileErrorBoundary>
<MobileErrorBoundary screenName="FuelLogEditDialog" key="fuel-edit-dialog">
<MobileErrorBoundary screenName="FuelLogEditDialog">
<FuelLogEditDialog
open={!!editingLog}
log={editingLog}
@@ -308,6 +310,7 @@ function App() {
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> },
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> },
{ key: "Stations", label: "Stations", icon: <LocalGasStationRoundedIcon /> },
{ key: "Documents", label: "Documents", icon: <DescriptionRoundedIcon /> },
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
];
@@ -479,6 +482,31 @@ function App() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Stations" && (
<motion.div
key="stations"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Stations">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
Loading stations screen...
</div>
</div>
</GlassCard>
</div>
}>
<StationsMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
</AnimatePresence>
<DebugInfo />
</Layout>
@@ -523,7 +551,7 @@ function App() {
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="/stations" element={<StationsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/vehicles" replace />} />
</Routes>

View File

@@ -0,0 +1,71 @@
/**
* Runtime Configuration Types
*
* Configuration loaded at container startup from secrets.
* Mirrors Kubernetes deployment patterns where secrets are mounted as files
* and read at runtime before application initialization.
*/
/**
* Application runtime configuration
* Loaded from window.CONFIG set by /config.js
*/
export interface AppConfig {
/** Google Maps JavaScript API key for map visualization */
googleMapsApiKey: string;
}
/**
* Window augmentation for runtime config
* config.js is loaded before the React app initializes
*/
declare global {
interface Window {
CONFIG?: AppConfig;
}
}
/**
* Get application configuration with validation
* Throws if required configuration is missing
*
* @returns Application configuration object
* @throws Error if configuration is not loaded or invalid
*/
export function getConfig(): AppConfig {
if (!window.CONFIG) {
throw new Error(
'Application configuration not loaded. Ensure config.js is loaded before the app initializes.'
);
}
return window.CONFIG;
}
/**
* Get Google Maps API key
* Returns empty string if key is not available (graceful fallback)
*
* @returns Google Maps API key or empty string
*/
export function getGoogleMapsApiKey(): string {
try {
const config = getConfig();
return config.googleMapsApiKey || '';
} catch {
console.warn(
'Google Maps API key not available. Maps functionality will be limited.'
);
return '';
}
}
/**
* Check if configuration is available
* Useful for conditional feature enablement
*
* @returns true if config is loaded, false otherwise
*/
export function isConfigLoaded(): boolean {
return !!window.CONFIG;
}

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { safeStorage } from '../utils/safe-storage';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Documents' | 'Settings';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
interface NavigationHistory {

View File

@@ -10,10 +10,11 @@ import { VehicleSelector } from './VehicleSelector';
import { DistanceInput } from './DistanceInput';
import { FuelTypeSelector } from './FuelTypeSelector';
import { UnitSystemDisplay } from './UnitSystemDisplay';
import { LocationInput } from './LocationInput';
import { StationPicker } from './StationPicker';
import { CostCalculator } from './CostCalculator';
import { useFuelLogs } from '../hooks/useFuelLogs';
import { useUserSettings } from '../hooks/useUserSettings';
import { useGeolocation } from '../../stations/hooks/useGeolocation';
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
const schema = z.object({
@@ -39,6 +40,9 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
const [useOdometer, setUseOdometer] = useState(false);
const formInitialized = useRef(false);
// Get user location for nearby station search
const { coordinates: userLocation } = useGeolocation();
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
resolver: zodResolver(schema),
mode: 'onChange',
@@ -282,7 +286,12 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
</Grid>
<Grid item xs={12}>
<Controller name="locationData" control={control} render={({ field }) => (
<LocationInput value={field.value as any} onChange={field.onChange as any} placeholder="Station location (optional)" />
<StationPicker
value={field.value as any}
onChange={field.onChange as any}
userLocation={userLocation}
placeholder="Station location (optional)"
/>
)} />
</Grid>
<Grid item xs={12}>

View File

@@ -0,0 +1,309 @@
/**
* @ai-summary Autocomplete component for selecting gas stations
* Integrates with saved stations and nearby search
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Autocomplete,
TextField,
Box,
Typography,
CircularProgress,
InputAdornment
} from '@mui/material';
import {
Bookmark as BookmarkIcon,
LocationOn as LocationIcon
} from '@mui/icons-material';
import { useSavedStations } from '../../stations/hooks/useSavedStations';
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
import { LocationData } from '../types/fuel-logs.types';
interface StationPickerProps {
/** Current location data value */
value?: LocationData;
/** Callback when station is selected */
onChange: (value?: LocationData) => void;
/** User's current location (optional) */
userLocation?: GeolocationCoordinates | null;
/** Placeholder text */
placeholder?: string;
/** Error message */
error?: string;
}
interface StationOption {
type: 'saved' | 'nearby' | 'manual';
station?: Station | SavedStation;
label: string;
group: string;
}
/**
* Format distance from meters to user-friendly string
*/
function formatDistance(meters: number): string {
const miles = meters / 1609.34;
if (miles < 0.1) return '< 0.1 mi';
if (miles < 10) return `${miles.toFixed(1)} mi`;
return `${Math.round(miles)} mi`;
}
/**
* Check if station is saved
*/
function isSavedStation(station: Station | SavedStation): station is SavedStation {
return 'userId' in station;
}
/**
* StationPicker Component
*
* Autocomplete component that allows users to:
* - Select from saved stations
* - Search nearby stations (if location available)
* - Enter manual text input
*
* Features:
* - Grouped options (Saved / Nearby)
* - Debounced search (300ms)
* - Loading indicators
* - Fallback to text input on API failure
* - Distance display
* - Bookmark icons for saved stations
*/
export const StationPicker: React.FC<StationPickerProps> = ({
value,
onChange,
userLocation,
placeholder = 'Station location (optional)',
error
}) => {
const [inputValue, setInputValue] = useState(value?.stationName || '');
const [searchTrigger, setSearchTrigger] = useState(0);
// Fetch saved stations
const { data: savedStations, isPending: savedLoading } = useSavedStations();
// Search mutation for nearby stations
const { mutate: searchStations, data: nearbyStations, isPending: searchLoading } = useStationsSearch();
// Debounced search effect
useEffect(() => {
if (!userLocation || !inputValue || inputValue.length < 2) {
return;
}
const timer = setTimeout(() => {
setSearchTrigger((prev) => prev + 1);
}, 300);
return () => clearTimeout(timer);
}, [inputValue, userLocation]);
// Execute search when trigger changes
useEffect(() => {
if (searchTrigger > 0 && userLocation) {
searchStations({
latitude: userLocation.latitude,
longitude: userLocation.longitude,
radius: 8000 // 5 miles in meters
});
}
}, [searchTrigger, userLocation, searchStations]);
// Build options list
const options: StationOption[] = useMemo(() => {
const opts: StationOption[] = [];
// Add saved stations first
if (savedStations && savedStations.length > 0) {
savedStations.forEach((station) => {
opts.push({
type: 'saved',
station,
label: station.nickname || station.name,
group: 'Saved Stations'
});
});
}
// Add nearby stations
if (nearbyStations && nearbyStations.length > 0) {
// Filter out stations already in saved list
const savedPlaceIds = new Set(savedStations?.map((s) => s.placeId) || []);
nearbyStations
.filter((station) => !savedPlaceIds.has(station.placeId))
.forEach((station) => {
opts.push({
type: 'nearby',
station,
label: station.name,
group: 'Nearby Stations'
});
});
}
return opts;
}, [savedStations, nearbyStations]);
// Handle option selection
const handleChange = useCallback(
(_event: React.SyntheticEvent, newValue: StationOption | string | null) => {
if (!newValue) {
onChange(undefined);
setInputValue('');
return;
}
// Manual text input (freeSolo)
if (typeof newValue === 'string') {
onChange({
stationName: newValue
});
setInputValue(newValue);
return;
}
// 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
}
});
setInputValue(station.name);
}
},
[onChange]
);
// Handle input text change
const handleInputChange = useCallback((_event: React.SyntheticEvent, newInputValue: string) => {
setInputValue(newInputValue);
}, []);
// Custom option rendering
const renderOption = useCallback(
(props: React.HTMLAttributes<HTMLLIElement>, option: StationOption | string) => {
// Handle manual text input option
if (typeof option === 'string') {
return (
<li {...props}>
<Typography variant="body2">{option}</Typography>
</li>
);
}
const { station, type } = option;
if (!station) return null;
const isSaved = isSavedStation(station);
const displayName = isSaved && station.nickname ? station.nickname : station.name;
const distance = station.distance ? formatDistance(station.distance) : null;
return (
<li {...props}>
<Box display="flex" alignItems="center" width="100%" gap={1}>
{type === 'saved' && (
<BookmarkIcon fontSize="small" color="primary" />
)}
{type === 'nearby' && (
<LocationIcon fontSize="small" color="action" />
)}
<Box flex={1} minWidth={0}>
<Typography variant="body2" noWrap>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{distance && `${distance}`}
{station.address}
</Typography>
</Box>
</Box>
</li>
);
},
[]
);
// Group options by category
const groupBy = useCallback((option: StationOption | string) => {
if (typeof option === 'string') return '';
return option.group;
}, []);
// Get option label
const getOptionLabel = useCallback((option: StationOption | string) => {
if (typeof option === 'string') return option;
return option.label;
}, []);
// Loading state
const isLoading = savedLoading || searchLoading;
return (
<Autocomplete
freeSolo
options={options}
value={null} // Controlled by inputValue
inputValue={inputValue}
onChange={handleChange}
onInputChange={handleInputChange}
groupBy={groupBy}
getOptionLabel={getOptionLabel}
renderOption={renderOption}
filterOptions={(opts) => opts} // Don't filter, we control options
loading={isLoading}
loadingText="Searching stations..."
noOptionsText={
userLocation
? 'No stations found. Type to enter manually.'
: 'Enable location to search nearby stations.'
}
renderInput={(params) => (
<TextField
{...params}
label="Location (optional)"
placeholder={placeholder}
error={!!error}
helperText={error || (userLocation ? 'Search saved or nearby stations' : 'Type station name')}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{isLoading && (
<InputAdornment position="end">
<CircularProgress size={20} />
</InputAdornment>
)}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
sx={{
'& .MuiAutocomplete-groupLabel': {
fontWeight: 600,
backgroundColor: 'grey.100',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.5px'
},
'& .MuiAutocomplete-option': {
minHeight: '44px', // Mobile touch target
padding: '8px 16px'
}
}}
/>
);
};

View File

@@ -0,0 +1,924 @@
# Gas Stations Feature - Frontend Documentation
## Overview
Complete frontend implementation for the Gas Stations feature. This feature enables users to search for nearby gas stations, view them on an interactive map, save favorites with custom notes, and integrate station data into fuel logging workflows.
## Feature Capabilities
- Search nearby gas stations using geolocation or manual coordinates
- View stations on interactive Google Maps
- Display station cards with name, address, distance, rating
- Save favorite stations with custom nicknames and notes
- Mobile-first responsive design with tab navigation
- Desktop layout with side-by-side map and list
- Integration with fuel logs (StationPicker component)
## Architecture
### Directory Structure
```
frontend/src/features/stations/
├── types/ # TypeScript definitions
│ └── stations.types.ts # API types, domain models
├── api/ # API client
│ └── stations.api.ts # HTTP calls to backend
├── hooks/ # React Query hooks
│ ├── useStationsSearch.ts # Search mutation
│ ├── useSavedStations.ts # Get saved stations query
│ ├── useSaveStation.ts # Save mutation
│ ├── useDeleteStation.ts # Delete mutation
│ └── useGeolocation.ts # Browser geolocation
├── utils/ # Utility functions
│ ├── distance.ts # Distance calculations
│ ├── maps-loader.ts # Lazy-load Google Maps API
│ └── map-utils.ts # Map helpers
├── components/ # React components
│ ├── StationCard.tsx # Individual station display
│ ├── StationsList.tsx # List of search results
│ ├── SavedStationsList.tsx # Saved stations list
│ ├── StationsSearchForm.tsx # Search input form
│ ├── StationMap.tsx # Interactive Google Map
│ └── index.ts # Component exports
├── pages/ # Page layouts
│ └── StationsPage.tsx # Desktop layout
├── mobile/ # Mobile layouts
│ └── StationsMobileScreen.tsx # Mobile tab navigation
└── README.md # This file
```
### Component Hierarchy
```
StationsPage (Desktop)
├── StationsSearchForm
│ └── useGeolocation
│ └── useStationsSearch
├── StationMap
│ └── Google Maps API
│ └── Station markers
└── StationsList
└── StationCard (multiple)
└── useSaveStation
└── useDeleteStation
StationsMobileScreen (Mobile)
├── Tab: Search
│ ├── StationsSearchForm
│ └── StationsList
├── Tab: Saved
│ └── SavedStationsList
│ └── StationCard (multiple)
└── Tab: Map
└── StationMap
```
## Key Components
### StationsSearchForm
**Purpose**: Search input with geolocation and manual coordinate entry
**Props**: None (uses hooks internally)
**Features**:
- Geolocation button (requests browser permission)
- Manual latitude/longitude inputs
- Radius slider (1-50 km)
- Loading states
- Error handling
**Usage**:
```tsx
import { StationsSearchForm } from '@/features/stations/components';
function MyPage() {
return <StationsSearchForm />;
}
```
**Hooks Used**:
- `useGeolocation()` - Browser geolocation API
- `useStationsSearch()` - Search mutation
**State Management**:
- Form state via React hooks
- Search results via React Query cache
### StationCard
**Purpose**: Display individual station with actions
**Props**:
```typescript
interface StationCardProps {
station: Station;
distance?: number;
isSaved?: boolean;
onSave?: () => void;
onDelete?: () => void;
}
```
**Features**:
- Station name, address, rating display
- Distance badge (if provided)
- Save/unsave button (heart icon)
- Directions link to Google Maps
- Touch-friendly 44px button heights
- Responsive layout
**Usage**:
```tsx
import { StationCard } from '@/features/stations/components';
function MyComponent() {
const { data: stations } = useStationsSearch();
return (
<div>
{stations?.map(station => (
<StationCard
key={station.placeId}
station={station}
distance={station.distance}
/>
))}
</div>
);
}
```
### StationMap
**Purpose**: Interactive Google Map with station markers
**Props**:
```typescript
interface StationMapProps {
stations: Station[];
center?: { lat: number; lng: number };
zoom?: number;
onStationClick?: (station: Station) => void;
}
```
**Features**:
- Lazy-load Google Maps API (via maps-loader.ts)
- Auto-fit bounds to show all stations
- Custom markers for gas stations
- Click handler for station selection
- Loading fallback UI
- Error handling (API key issues)
**Usage**:
```tsx
import { StationMap } from '@/features/stations/components';
function MyPage() {
const { data: stations } = useStationsSearch();
return (
<StationMap
stations={stations || []}
onStationClick={(station) => {
console.log('Selected:', station);
}}
/>
);
}
```
**Google Maps API Loading**:
The map component uses `maps-loader.ts` to lazy-load the Google Maps JavaScript API:
```typescript
import { loadGoogleMaps } from '@/features/stations/utils/maps-loader';
useEffect(() => {
loadGoogleMaps()
.then(() => {
// Initialize map
})
.catch((error) => {
// Handle error
});
}, []);
```
### StationsList
**Purpose**: Scrollable list of station cards
**Props**:
```typescript
interface StationsListProps {
stations: Station[];
isLoading?: boolean;
onStationSelect?: (station: Station) => void;
}
```
**Features**:
- Virtualized list for performance (if many results)
- Empty state messaging
- Loading skeletons
- Responsive grid layout
**Usage**:
```tsx
import { StationsList } from '@/features/stations/components';
function MyComponent() {
const { data: stations, isLoading } = useStationsSearch();
return (
<StationsList
stations={stations || []}
isLoading={isLoading}
/>
);
}
```
### SavedStationsList
**Purpose**: List of user's saved favorite stations
**Props**: None (uses hooks internally)
**Features**:
- Fetches saved stations on mount
- Edit nickname/notes inline
- Delete confirmation
- Empty state for no saved stations
- Pull-to-refresh (mobile)
**Usage**:
```tsx
import { SavedStationsList } from '@/features/stations/components';
function SavedTab() {
return <SavedStationsList />;
}
```
**Hooks Used**:
- `useSavedStations()` - Fetch saved stations
- `useDeleteStation()` - Remove saved station
## React Query Hooks
### useStationsSearch
**Purpose**: Search for nearby gas stations
**Type**: Mutation (not cached, each search is independent)
**Parameters**:
```typescript
interface StationSearchRequest {
latitude: number;
longitude: number;
radius?: number; // In meters, default 5000
fuelType?: string;
}
```
**Returns**:
```typescript
{
mutate: (request: StationSearchRequest) => void;
isPending: boolean;
isError: boolean;
error: ApiError | null;
data: Station[] | undefined;
}
```
**Usage**:
```tsx
const { mutate: search, isPending, data, error } = useStationsSearch({
onSuccess: (stations) => {
console.log('Found stations:', stations);
},
onError: (error) => {
console.error('Search failed:', error.message);
}
});
const handleSearch = () => {
search({
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
});
};
```
### useSavedStations
**Purpose**: Fetch all saved stations for current user
**Type**: Query (cached with 5-minute stale time)
**Parameters**: None (user identified by JWT)
**Returns**:
```typescript
{
data: SavedStation[] | undefined;
isLoading: boolean;
isError: boolean;
error: ApiError | null;
refetch: () => void;
}
```
**Usage**:
```tsx
const { data: savedStations, isLoading, refetch } = useSavedStations();
useEffect(() => {
if (savedStations) {
console.log('User has', savedStations.length, 'saved stations');
}
}, [savedStations]);
```
### useSaveStation
**Purpose**: Save a station to favorites
**Type**: Mutation (invalidates saved stations cache)
**Parameters**:
```typescript
interface SaveStationRequest {
placeId: string;
nickname?: string;
notes?: string;
isFavorite?: boolean;
}
```
**Returns**:
```typescript
{
mutate: (request: SaveStationRequest) => void;
isPending: boolean;
isError: boolean;
error: ApiError | null;
}
```
**Usage**:
```tsx
const { mutate: saveStation, isPending } = useSaveStation({
onSuccess: () => {
toast.success('Station saved!');
},
onError: (error) => {
toast.error(`Failed to save: ${error.message}`);
}
});
const handleSave = (station: Station) => {
saveStation({
placeId: station.placeId,
nickname: 'My Favorite Station',
isFavorite: true
});
};
```
### useDeleteStation
**Purpose**: Remove saved station from favorites
**Type**: Mutation (invalidates saved stations cache)
**Parameters**: `placeId: string`
**Returns**:
```typescript
{
mutate: (placeId: string) => void;
isPending: boolean;
isError: boolean;
error: ApiError | null;
}
```
**Usage**:
```tsx
const { mutate: deleteStation, isPending } = useDeleteStation({
onSuccess: () => {
toast.success('Station removed');
}
});
const handleDelete = (placeId: string) => {
if (confirm('Remove this station?')) {
deleteStation(placeId);
}
};
```
### useGeolocation
**Purpose**: Access browser geolocation API
**Type**: Hook (not React Query, custom hook)
**Parameters**: None
**Returns**:
```typescript
{
location: { latitude: number; longitude: number } | null;
error: string | null;
isLoading: boolean;
requestLocation: () => void;
}
```
**Usage**:
```tsx
const { location, error, isLoading, requestLocation } = useGeolocation();
const handleUseCurrentLocation = () => {
requestLocation();
};
useEffect(() => {
if (location) {
console.log('User location:', location);
// Trigger search with location
}
}, [location]);
if (error) {
return <div>Geolocation error: {error}</div>;
}
```
**Browser Permissions**:
- Requests `navigator.geolocation.getCurrentPosition`
- User must grant permission in browser
- Fallback to manual coordinates if denied
## Utility Functions
### distance.ts
**Purpose**: Calculate distance between two coordinates
**Functions**:
```typescript
/**
* Calculate distance using Haversine formula
* @param lat1 First point latitude
* @param lon1 First point longitude
* @param lat2 Second point latitude
* @param lon2 Second point longitude
* @returns Distance in meters
*/
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number;
/**
* Format distance for display
* @param meters Distance in meters
* @returns Formatted string (e.g., "1.5 km", "350 m")
*/
export function formatDistance(meters: number): string;
```
**Usage**:
```tsx
import { calculateDistance, formatDistance } from '@/features/stations/utils/distance';
const distanceInMeters = calculateDistance(
37.7749, -122.4194, // San Francisco
37.7849, -122.4094 // Station location
);
const formatted = formatDistance(distanceInMeters); // "1.2 km"
```
### maps-loader.ts
**Purpose**: Lazy-load Google Maps JavaScript API
**Functions**:
```typescript
/**
* Load Google Maps API dynamically
* Reads API key from runtime config
* Only loads once (cached promise)
*/
export function loadGoogleMaps(): Promise<void>;
/**
* Check if Google Maps API is already loaded
*/
export function isGoogleMapsLoaded(): boolean;
```
**Usage**:
```tsx
import { loadGoogleMaps } from '@/features/stations/utils/maps-loader';
useEffect(() => {
loadGoogleMaps()
.then(() => {
// google.maps is now available
const map = new google.maps.Map(mapRef.current, {
center: { lat: 37.7749, lng: -122.4194 },
zoom: 12
});
})
.catch((error) => {
console.error('Failed to load Google Maps:', error);
});
}, []);
```
**Runtime Configuration**:
The loader uses the runtime config pattern to access the API key:
```typescript
import { getGoogleMapsApiKey } from '@/core/config/config.types';
const apiKey = getGoogleMapsApiKey();
// Loads script: https://maps.googleapis.com/maps/api/js?key=...&libraries=places
```
### map-utils.ts
**Purpose**: Helper functions for map operations
**Functions**:
```typescript
/**
* Fit map bounds to show all stations
*/
export function fitBoundsToStations(
map: google.maps.Map,
stations: Station[]
): void;
/**
* Create marker for station
*/
export function createStationMarker(
map: google.maps.Map,
station: Station,
onClick?: (station: Station) => void
): google.maps.Marker;
```
## Runtime Configuration
The Gas Stations feature uses MotoVaultPro's K8s-aligned runtime configuration pattern for the Google Maps API key.
### How It Works
1. **Container Startup**: `/app/load-config.sh` reads `/run/secrets/google-maps-api-key`
2. **Config Generation**: Creates `/usr/share/nginx/html/config.js`
3. **App Access**: React app reads `window.CONFIG.googleMapsApiKey`
### Accessing Configuration
```typescript
import { getGoogleMapsApiKey } from '@/core/config/config.types';
export function MyComponent() {
const apiKey = getGoogleMapsApiKey();
if (!apiKey) {
return <div>Google Maps API key not configured</div>;
}
// Use API key
return <MapComponent apiKey={apiKey} />;
}
```
### Development Setup
For local development (Vite dev server):
```bash
# Set up secrets
mkdir -p ./secrets/app
echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
# Alternatively, set environment variable
export VITE_GOOGLE_MAPS_API_KEY=YOUR_API_KEY
```
See `/frontend/docs/RUNTIME-CONFIG.md` for complete documentation.
## Adding New Functionality
### Adding a New Component
1. Create component in `components/` directory
2. Follow naming convention: `ComponentName.tsx`
3. Export from `components/index.ts`
4. Add types to `types/stations.types.ts` if needed
5. Write unit tests in `__tests__/` directory
**Example**:
```tsx
// components/StationRating.tsx
import { Station } from '../types/stations.types';
interface StationRatingProps {
station: Station;
}
export function StationRating({ station }: StationRatingProps) {
if (!station.rating) return null;
return (
<div className="flex items-center gap-1">
<span className="text-yellow-500"></span>
<span>{station.rating.toFixed(1)}</span>
</div>
);
}
// components/index.ts
export { StationRating } from './StationRating';
```
### Adding a New Hook
1. Create hook in `hooks/` directory
2. Use React Query for server state
3. Follow naming convention: `useFeatureName.ts`
4. Document with JSDoc comments and usage examples
**Example**:
```tsx
// hooks/useStationDetails.ts
import { useQuery } from '@tanstack/react-query';
import { stationsApi } from '../api/stations.api';
/**
* Fetch detailed information for a specific station
* Cached for 10 minutes
*/
export function useStationDetails(placeId: string) {
return useQuery({
queryKey: ['station', placeId],
queryFn: () => stationsApi.getStationDetails(placeId),
staleTime: 10 * 60 * 1000, // 10 minutes
enabled: !!placeId
});
}
```
### Adding a New API Endpoint
1. Add method to `api/stations.api.ts`
2. Add types to `types/stations.types.ts`
3. Create hook in `hooks/` directory
4. Update documentation
**Example**:
```typescript
// api/stations.api.ts
export const stationsApi = {
// ... existing methods
async getNearbyPrices(placeId: string): Promise<FuelPrices> {
const response = await apiClient.get(`/api/stations/${placeId}/prices`);
return response.data;
}
};
// types/stations.types.ts
export interface FuelPrices {
regular: number;
premium: number;
diesel: number;
lastUpdated: string;
}
// hooks/useStationPrices.ts
export function useStationPrices(placeId: string) {
return useQuery({
queryKey: ['station-prices', placeId],
queryFn: () => stationsApi.getNearbyPrices(placeId),
staleTime: 5 * 60 * 1000 // 5 minutes
});
}
```
## Testing Components
### Unit Testing
**Location**: `__tests__/components/`
**Tools**: Vitest + React Testing Library
**Example**:
```tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { StationCard } from '../components/StationCard';
import { mockStations } from './fixtures';
describe('StationCard', () => {
it('renders station information', () => {
render(<StationCard station={mockStations[0]} />);
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
expect(screen.getByText(/123 Main St/)).toBeInTheDocument();
});
it('calls onSave when save button clicked', () => {
const onSave = vi.fn();
render(<StationCard station={mockStations[0]} onSave={onSave} />);
fireEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onSave).toHaveBeenCalledTimes(1);
});
});
```
### Integration Testing
Test complete workflows:
```tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StationsPage } from '../pages/StationsPage';
describe('StationsPage Integration', () => {
it('searches and displays stations', async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<StationsPage />
</QueryClientProvider>
);
// Fill search form
const latInput = screen.getByLabelText(/latitude/i);
fireEvent.change(latInput, { target: { value: '37.7749' } });
// Submit search
const searchButton = screen.getByRole('button', { name: /search/i });
fireEvent.click(searchButton);
// Wait for results
await waitFor(() => {
expect(screen.getByText(/Shell Gas Station/i)).toBeInTheDocument();
});
});
});
```
## Performance Considerations
### Lazy Loading
- Google Maps API loaded only when map component mounts
- Station images lazy-loaded with Intersection Observer
- Route-based code splitting for page components
### Caching Strategy
- Search results not cached (each search is independent)
- Saved stations cached for 5 minutes
- Google Maps API script cached by browser
### Optimization Tips
1. **Limit Search Radius**: Default 5km, max 50km
2. **Pagination**: Load first 20 results, paginate if needed
3. **Virtual Scrolling**: For large result sets (100+ stations)
4. **Debounce Search**: Wait 500ms after user stops typing
5. **Memoize Calculations**: Use `useMemo` for distance calculations
**Example**:
```tsx
const sortedStations = useMemo(() => {
if (!stations || !userLocation) return stations;
return stations
.map(station => ({
...station,
distance: calculateDistance(
userLocation.latitude,
userLocation.longitude,
station.latitude,
station.longitude
)
}))
.sort((a, b) => a.distance - b.distance);
}, [stations, userLocation]);
```
## Mobile Responsiveness
### Design Principles
- **Mobile-first**: Design for mobile, enhance for desktop
- **Touch targets**: Minimum 44px height for buttons
- **Readable text**: Minimum 16px font size (no zoom on iOS)
- **Accessible contrast**: WCAG AA compliance
### Responsive Breakpoints
```css
/* Mobile (default) */
@media (min-width: 640px) { /* sm */
/* Small tablets */
}
@media (min-width: 768px) { /* md */
/* Tablets */
}
@media (min-width: 1024px) { /* lg */
/* Desktop - switch to side-by-side layout */
}
```
### Layout Differences
**Mobile** (< 1024px):
- Bottom tab navigation (Search, Saved, Map)
- Full-width components
- Stack vertically
**Desktop** (>= 1024px):
- Side-by-side: Map on left, List on right
- Fixed positions with scroll
- Larger interactive areas
## Troubleshooting
### Google Maps Not Loading
**Symptom**: Map shows blank or error message
**Solutions**:
1. Check API key in config:
```bash
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
```
2. Verify API key in Google Cloud Console
3. Check browser console for errors
4. Verify Maps JavaScript API is enabled
### Geolocation Not Working
**Symptom**: "Use Current Location" button doesn't work
**Solutions**:
1. Check browser permissions (user must allow)
2. Requires HTTPS in production (not localhost)
3. Some browsers block geolocation in iframes
4. Fallback to manual coordinates
### Stations Not Saving
**Symptom**: Save button doesn't work or errors
**Solutions**:
1. Verify user is authenticated (JWT present)
2. Check station is in cache (search first)
3. Review network tab for API errors
4. Check backend logs for issues
### Search Returns No Results
**Symptom**: Search completes but no stations shown
**Solutions**:
1. Verify location is correct (lat/lng valid)
2. Try larger radius (default 5km may be too small)
3. Check Google Maps API quota (not exceeded)
4. Review backend circuit breaker state
## References
- Backend API: `/backend/src/features/stations/docs/API.md`
- Backend Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- Testing Guide: `/backend/src/features/stations/docs/TESTING.md`
- Google Maps Setup: `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
- Runtime Config: `/frontend/docs/RUNTIME-CONFIG.md`
- Main README: `/backend/src/features/stations/README.md`

View File

@@ -0,0 +1,295 @@
/**
* @ai-summary Tests for stations API client
*/
import axios from 'axios';
import { stationsApi } from '../../api/stations.api';
import { Station, StationSearchRequest } from '../../types/stations.types';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const mockStations: Station[] = [
{
placeId: 'test-1',
name: 'Shell Station',
address: '123 Main St',
latitude: 37.7749,
longitude: -122.4194,
rating: 4.2,
distance: 250
}
];
describe('stationsApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('searchStations', () => {
it('should search for stations with valid request', async () => {
const request: StationSearchRequest = {
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
};
mockedAxios.post.mockResolvedValue({
data: { stations: mockStations }
});
const result = await stationsApi.searchStations(request);
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/stations/search',
request
);
expect(result).toEqual(mockStations);
});
it('should handle search without radius', async () => {
const request: StationSearchRequest = {
latitude: 37.7749,
longitude: -122.4194
};
mockedAxios.post.mockResolvedValue({
data: { stations: mockStations }
});
await stationsApi.searchStations(request);
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/stations/search',
request
);
});
it('should handle API errors', async () => {
mockedAxios.post.mockRejectedValue(new Error('Network error'));
await expect(
stationsApi.searchStations({
latitude: 37.7749,
longitude: -122.4194
})
).rejects.toThrow('Network error');
});
it('should handle 401 unauthorized', async () => {
mockedAxios.post.mockRejectedValue({
response: { status: 401, data: { message: 'Unauthorized' } }
});
await expect(
stationsApi.searchStations({
latitude: 37.7749,
longitude: -122.4194
})
).rejects.toBeDefined();
});
it('should handle 500 server error', async () => {
mockedAxios.post.mockRejectedValue({
response: { status: 500, data: { message: 'Internal server error' } }
});
await expect(
stationsApi.searchStations({
latitude: 37.7749,
longitude: -122.4194
})
).rejects.toBeDefined();
});
});
describe('saveStation', () => {
it('should save a station with metadata', async () => {
const placeId = 'test-place-id';
const data = {
nickname: 'Work Station',
notes: 'Best prices',
isFavorite: true
};
mockedAxios.post.mockResolvedValue({
data: { id: '123', ...data, placeId }
});
const result = await stationsApi.saveStation(placeId, data);
expect(mockedAxios.post).toHaveBeenCalledWith('/api/stations/save', {
placeId,
...data
});
expect(result.placeId).toBe(placeId);
});
it('should save station without optional fields', async () => {
const placeId = 'test-place-id';
mockedAxios.post.mockResolvedValue({
data: { id: '123', placeId }
});
await stationsApi.saveStation(placeId);
expect(mockedAxios.post).toHaveBeenCalledWith('/api/stations/save', {
placeId
});
});
it('should handle save errors', async () => {
mockedAxios.post.mockRejectedValue({
response: { status: 404, data: { message: 'Station not found' } }
});
await expect(
stationsApi.saveStation('invalid-id')
).rejects.toBeDefined();
});
});
describe('getSavedStations', () => {
it('should fetch all saved stations', async () => {
mockedAxios.get.mockResolvedValue({
data: mockStations
});
const result = await stationsApi.getSavedStations();
expect(mockedAxios.get).toHaveBeenCalledWith('/api/stations/saved');
expect(result).toEqual(mockStations);
});
it('should return empty array when no saved stations', async () => {
mockedAxios.get.mockResolvedValue({
data: []
});
const result = await stationsApi.getSavedStations();
expect(result).toEqual([]);
});
it('should handle fetch errors', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'));
await expect(stationsApi.getSavedStations()).rejects.toThrow(
'Network error'
);
});
});
describe('deleteSavedStation', () => {
it('should delete a station by placeId', async () => {
const placeId = 'test-place-id';
mockedAxios.delete.mockResolvedValue({ status: 204 });
await stationsApi.deleteSavedStation(placeId);
expect(mockedAxios.delete).toHaveBeenCalledWith(
`/api/stations/saved/${placeId}`
);
});
it('should handle 404 not found', async () => {
mockedAxios.delete.mockRejectedValue({
response: { status: 404, data: { message: 'Not found' } }
});
await expect(
stationsApi.deleteSavedStation('invalid-id')
).rejects.toBeDefined();
});
it('should handle delete errors', async () => {
mockedAxios.delete.mockRejectedValue(new Error('Network error'));
await expect(
stationsApi.deleteSavedStation('test-id')
).rejects.toThrow('Network error');
});
});
describe('URL Construction', () => {
it('should use correct API base path', async () => {
mockedAxios.post.mockResolvedValue({ data: { stations: [] } });
await stationsApi.searchStations({
latitude: 37.7749,
longitude: -122.4194
});
const callUrl = mockedAxios.post.mock.calls[0][0];
expect(callUrl).toContain('/api/stations');
});
it('should construct correct saved station URL', async () => {
mockedAxios.delete.mockResolvedValue({ status: 204 });
await stationsApi.deleteSavedStation('test-place-id');
const callUrl = mockedAxios.delete.mock.calls[0][0];
expect(callUrl).toBe('/api/stations/saved/test-place-id');
});
});
describe('Request Payload Validation', () => {
it('should send correct payload for search', async () => {
const request = {
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
};
mockedAxios.post.mockResolvedValue({ data: { stations: [] } });
await stationsApi.searchStations(request);
const payload = mockedAxios.post.mock.calls[0][1];
expect(payload).toEqual(request);
});
it('should send correct payload for save', async () => {
const placeId = 'test-id';
const data = { nickname: 'Test', isFavorite: true };
mockedAxios.post.mockResolvedValue({ data: {} });
await stationsApi.saveStation(placeId, data);
const payload = mockedAxios.post.mock.calls[0][1];
expect(payload).toEqual({ placeId, ...data });
});
});
describe('Response Parsing', () => {
it('should parse search response correctly', async () => {
const responseData = {
stations: mockStations,
searchLocation: { latitude: 37.7749, longitude: -122.4194 },
searchRadius: 5000
};
mockedAxios.post.mockResolvedValue({ data: responseData });
const result = await stationsApi.searchStations({
latitude: 37.7749,
longitude: -122.4194
});
expect(result).toEqual(mockStations);
});
it('should parse saved stations response', async () => {
mockedAxios.get.mockResolvedValue({ data: mockStations });
const result = await stationsApi.getSavedStations();
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual(mockStations);
});
});
});

View File

@@ -0,0 +1,161 @@
/**
* @ai-summary Tests for StationCard component
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { StationCard } from '../../components/StationCard';
import { Station } from '../../types/stations.types';
const mockStation: Station = {
placeId: 'test-place-id',
name: 'Shell Gas Station',
address: '123 Main St, San Francisco, CA 94105',
latitude: 37.7749,
longitude: -122.4194,
rating: 4.2,
distance: 250,
photoUrl: 'https://example.com/photo.jpg'
};
describe('StationCard', () => {
beforeEach(() => {
jest.clearAllMocks();
window.open = jest.fn();
});
describe('Rendering', () => {
it('should render station name and address', () => {
render(<StationCard station={mockStation} isSaved={false} />);
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
expect(screen.getByText('123 Main St, San Francisco, CA 94105')).toBeInTheDocument();
});
it('should render station photo if available', () => {
render(<StationCard station={mockStation} isSaved={false} />);
const photo = screen.getByAltText('Shell Gas Station');
expect(photo).toBeInTheDocument();
expect(photo).toHaveAttribute('src', 'https://example.com/photo.jpg');
});
it('should render rating when available', () => {
render(<StationCard station={mockStation} isSaved={false} />);
expect(screen.getByText('4.2')).toBeInTheDocument();
});
it('should render distance chip', () => {
render(<StationCard station={mockStation} isSaved={false} />);
expect(screen.getByText(/mi/)).toBeInTheDocument();
});
it('should not crash when photo is missing', () => {
const stationWithoutPhoto = { ...mockStation, photoUrl: undefined };
render(<StationCard station={stationWithoutPhoto} isSaved={false} />);
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
});
});
describe('Save/Delete Actions', () => {
it('should call onSave when bookmark button clicked (not saved)', () => {
const onSave = jest.fn();
render(<StationCard station={mockStation} isSaved={false} onSave={onSave} />);
const bookmarkButton = screen.getByTitle('Add to favorites');
fireEvent.click(bookmarkButton);
expect(onSave).toHaveBeenCalledWith(mockStation);
});
it('should call onDelete when bookmark button clicked (saved)', () => {
const onDelete = jest.fn();
render(<StationCard station={mockStation} isSaved={true} onDelete={onDelete} />);
const bookmarkButton = screen.getByTitle('Remove from favorites');
fireEvent.click(bookmarkButton);
expect(onDelete).toHaveBeenCalledWith(mockStation.placeId);
});
it('should show filled bookmark icon when saved', () => {
render(<StationCard station={mockStation} isSaved={true} />);
const bookmarkButton = screen.getByTitle('Remove from favorites');
expect(bookmarkButton).toBeInTheDocument();
});
it('should show outline bookmark icon when not saved', () => {
render(<StationCard station={mockStation} isSaved={false} />);
const bookmarkButton = screen.getByTitle('Add to favorites');
expect(bookmarkButton).toBeInTheDocument();
});
});
describe('Directions Link', () => {
it('should open Google Maps when directions button clicked', () => {
render(<StationCard station={mockStation} isSaved={false} />);
const directionsButton = screen.getByTitle('Get directions');
fireEvent.click(directionsButton);
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining('google.com/maps'),
'_blank'
);
});
it('should encode address in directions URL', () => {
render(<StationCard station={mockStation} isSaved={false} />);
const directionsButton = screen.getByTitle('Get directions');
fireEvent.click(directionsButton);
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining(encodeURIComponent(mockStation.address)),
'_blank'
);
});
});
describe('Touch Targets', () => {
it('should have minimum 44px button heights', () => {
const { container } = render(<StationCard station={mockStation} isSaved={false} />);
const buttons = container.querySelectorAll('button');
buttons.forEach((button) => {
const styles = window.getComputedStyle(button);
const minHeight = parseInt(styles.minHeight);
expect(minHeight).toBeGreaterThanOrEqual(44);
});
});
});
describe('Card Selection', () => {
it('should call onSelect when card is clicked', () => {
const onSelect = jest.fn();
render(<StationCard station={mockStation} isSaved={false} onSelect={onSelect} />);
const card = screen.getByText('Shell Gas Station').closest('.MuiCard-root');
if (card) {
fireEvent.click(card);
expect(onSelect).toHaveBeenCalledWith(mockStation);
}
});
it('should not call onSelect when button is clicked', () => {
const onSelect = jest.fn();
render(<StationCard station={mockStation} isSaved={false} onSelect={onSelect} />);
const directionsButton = screen.getByTitle('Get directions');
fireEvent.click(directionsButton);
expect(onSelect).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,202 @@
/**
* @ai-summary Tests for useStationsSearch hook
*/
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useStationsSearch } from '../../hooks/useStationsSearch';
import { stationsApi } from '../../api/stations.api';
import { Station } from '../../types/stations.types';
jest.mock('../../api/stations.api');
const mockStations: Station[] = [
{
placeId: 'test-1',
name: 'Shell Station',
address: '123 Main St',
latitude: 37.7749,
longitude: -122.4194,
rating: 4.2,
distance: 250
},
{
placeId: 'test-2',
name: 'Chevron Station',
address: '456 Market St',
latitude: 37.7923,
longitude: -122.3989,
rating: 4.5,
distance: 1200
}
];
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
});
return ({ children }: { children: React.ReactNode }): React.ReactElement =>
React.createElement(
QueryClientProvider,
{ client: queryClient },
children
);
};
describe('useStationsSearch', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Search Execution', () => {
it('should search for stations and return results', async () => {
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
const { result } = renderHook(() => useStationsSearch(), {
wrapper: createWrapper()
});
result.current.mutate({
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockStations);
expect(stationsApi.searchStations).toHaveBeenCalledWith({
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
});
});
it('should handle search with custom radius', async () => {
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
const { result } = renderHook(() => useStationsSearch(), {
wrapper: createWrapper()
});
result.current.mutate({
latitude: 37.7749,
longitude: -122.4194,
radius: 10000
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(stationsApi.searchStations).toHaveBeenCalledWith({
latitude: 37.7749,
longitude: -122.4194,
radius: 10000
});
});
});
describe('Loading States', () => {
it('should show pending state during search', () => {
(stationsApi.searchStations as jest.Mock).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockStations), 100))
);
const { result } = renderHook(() => useStationsSearch(), {
wrapper: createWrapper()
});
result.current.mutate({
latitude: 37.7749,
longitude: -122.4194
});
expect(result.current.isPending).toBe(true);
});
it('should clear pending state after success', async () => {
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
const { result } = renderHook(() => useStationsSearch(), {
wrapper: createWrapper()
});
result.current.mutate({
latitude: 37.7749,
longitude: -122.4194
});
await waitFor(() => {
expect(result.current.isPending).toBe(false);
});
});
});
describe('Error Handling', () => {
it('should handle API errors', async () => {
const error = new Error('API Error');
(stationsApi.searchStations as jest.Mock).mockRejectedValue(error);
const { result } = renderHook(() => useStationsSearch(), {
wrapper: createWrapper()
});
result.current.mutate({
latitude: 37.7749,
longitude: -122.4194
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
});
it('should call onError callback on failure', async () => {
const error = new Error('Network error');
(stationsApi.searchStations as jest.Mock).mockRejectedValue(error);
const onError = jest.fn();
const { result } = renderHook(() => useStationsSearch({ onError }), {
wrapper: createWrapper()
});
result.current.mutate({
latitude: 37.7749,
longitude: -122.4194
});
await waitFor(() => {
expect(onError).toHaveBeenCalled();
});
});
});
describe('Success Callback', () => {
it('should call onSuccess callback with data', async () => {
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
const onSuccess = jest.fn();
const { result } = renderHook(() => useStationsSearch({ onSuccess }), {
wrapper: createWrapper()
});
result.current.mutate({
latitude: 37.7749,
longitude: -122.4194
});
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith(mockStations);
});
});
});
});

View File

@@ -0,0 +1,165 @@
/**
* @ai-summary API client for Gas Stations feature
*/
import { apiClient } from '@/core/api/client';
import {
Station,
StationSearchRequest,
StationSearchResponse,
SavedStation,
SaveStationData,
ApiError
} from '../types/stations.types';
const API_BASE = '/api/stations';
class StationsApiClient {
/**
* Search for nearby gas stations
* @param request Search parameters (latitude, longitude, radius)
* @returns Promise with stations found
*/
async searchStations(request: StationSearchRequest): Promise<Station[]> {
try {
const response = await apiClient.post<StationSearchResponse>(
`${API_BASE}/search`,
{
latitude: request.latitude,
longitude: request.longitude,
radius: request.radius || 5000,
fuelType: request.fuelType
}
);
return response.data.stations || [];
} catch (error) {
console.error('Station search failed:', error);
throw this.handleError(error);
}
}
/**
* Save a station to user favorites
* @param placeId Google Places ID
* @param data Station metadata (nickname, notes, isFavorite)
* @returns Saved station record
*/
async saveStation(
placeId: string,
data: SaveStationData
): Promise<SavedStation> {
try {
const response = await apiClient.post<SavedStation>(
`${API_BASE}/save`,
{
placeId,
...data
}
);
return response.data;
} catch (error) {
console.error('Save station failed:', error);
throw this.handleError(error);
}
}
/**
* Get all saved stations for current user
* @returns Array of saved stations
*/
async getSavedStations(): Promise<SavedStation[]> {
try {
const response = await apiClient.get<SavedStation[]>(
`${API_BASE}/saved`
);
return response.data || [];
} catch (error) {
console.error('Get saved stations failed:', error);
throw this.handleError(error);
}
}
/**
* Get a specific saved station
* @param placeId Google Places ID
* @returns Saved station details or null
*/
async getSavedStation(placeId: string): Promise<SavedStation | null> {
try {
const response = await apiClient.get<SavedStation>(
`${API_BASE}/saved/${placeId}`
);
return response.data || null;
} catch (error) {
if ((error as any)?.response?.status === 404) {
return null;
}
console.error('Get saved station failed:', error);
throw this.handleError(error);
}
}
/**
* Delete a saved station
* @param placeId Google Places ID
*/
async deleteSavedStation(placeId: string): Promise<void> {
try {
await apiClient.delete(`${API_BASE}/saved/${placeId}`);
} catch (error) {
console.error('Delete saved station failed:', error);
throw this.handleError(error);
}
}
/**
* Update a saved station's metadata
* @param placeId Google Places ID
* @param data Updated metadata
*/
async updateSavedStation(
placeId: string,
data: Partial<SaveStationData>
): Promise<SavedStation> {
try {
const response = await apiClient.patch<SavedStation>(
`${API_BASE}/saved/${placeId}`,
data
);
return response.data;
} catch (error) {
console.error('Update saved station failed:', error);
throw this.handleError(error);
}
}
/**
* Handle API errors with proper typing
*/
private handleError(error: unknown): ApiError {
const axiosError = error as any;
if (axiosError?.response?.data) {
return axiosError.response.data as ApiError;
}
if (axiosError?.message) {
return {
message: axiosError.message,
code: 'UNKNOWN_ERROR'
};
}
return {
message: 'An unexpected error occurred',
code: 'UNKNOWN_ERROR'
};
}
}
export const stationsApi = new StationsApiClient();

View File

@@ -0,0 +1,163 @@
/**
* @ai-summary List of user's saved/favorited stations
*/
import React from 'react';
import {
List,
ListItem,
ListItemButton,
ListItemText,
ListItemSecondaryAction,
IconButton,
Box,
Typography,
Chip,
Divider,
Alert
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance';
interface SavedStationsListProps {
stations: SavedStation[];
loading?: boolean;
error?: string | null;
onSelectStation?: (station: SavedStation) => void;
onDeleteStation?: (placeId: string) => void;
}
/**
* Vertical list of saved stations with delete option
*/
export const SavedStationsList: React.FC<SavedStationsListProps> = ({
stations,
error = null,
onSelectStation,
onDeleteStation
}) => {
// Error state
if (error) {
return (
<Box sx={{ padding: 2 }}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
// Empty state
if (stations.length === 0) {
return (
<Box sx={{ textAlign: 'center', padding: 3 }}>
<Typography variant="body1" color="textSecondary">
No saved stations yet. Save stations from search results to access them
quickly.
</Typography>
</Box>
);
}
return (
<List
sx={{
width: '100%',
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 }}
>
<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'
}}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
{index < stations.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
);
};
export default SavedStationsList;

View File

@@ -0,0 +1,174 @@
/**
* @ai-summary Individual station card component
*/
import React from 'react';
import {
Card,
CardContent,
CardMedia,
Typography,
Chip,
IconButton,
Box,
Rating
} from '@mui/material';
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 { formatDistance } from '../utils/distance';
interface StationCardProps {
station: Station;
isSaved: boolean;
onSave?: (station: Station) => void;
onDelete?: (placeId: string) => void;
onSelect?: (station: Station) => void;
}
/**
* Station card showing station details with save/delete buttons
* Responsive design: min 44px touch targets on mobile
*/
export const StationCard: React.FC<StationCardProps> = ({
station,
isSaved,
onSave,
onDelete,
onSelect
}) => {
const handleSaveClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isSaved) {
onDelete?.(station.placeId);
} else {
onSave?.(station);
}
};
const handleDirections = (e: React.MouseEvent) => {
e.stopPropagation();
const mapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(station.address)}`;
window.open(mapsUrl, '_blank');
};
return (
<Card
onClick={() => onSelect?.(station)}
sx={{
cursor: 'pointer',
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 3
},
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{station.photoUrl && (
<CardMedia
component="img"
height="200"
image={station.photoUrl}
alt={station.name}
sx={{ objectFit: 'cover' }}
/>
)}
<CardContent sx={{ flexGrow: 1 }}>
{/* Station Name */}
<Typography variant="h6" component="div" noWrap>
{station.name}
</Typography>
{/* Address */}
<Typography
variant="body2"
color="textSecondary"
sx={{
marginTop: 0.5,
marginBottom: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}
>
{station.address}
</Typography>
{/* Rating and Distance */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
{station.rating > 0 && (
<>
<Rating
value={station.rating / 5}
precision={0.1}
readOnly
size="small"
/>
<Typography variant="body2" color="textSecondary">
{station.rating.toFixed(1)}
</Typography>
</>
)}
</Box>
{/* Distance */}
{station.distance !== undefined && (
<Chip
label={formatDistance(station.distance)}
size="small"
variant="outlined"
sx={{ marginBottom: 1 }}
/>
)}
</CardContent>
{/* Actions */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
padding: 1,
borderTop: '1px solid #e0e0e0',
minHeight: '44px',
alignItems: 'center'
}}
>
<IconButton
size="large"
onClick={handleDirections}
title="Get directions"
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1
}}
>
<DirectionsIcon />
</IconButton>
<IconButton
size="large"
onClick={handleSaveClick}
title={isSaved ? 'Remove from favorites' : 'Add to favorites'}
sx={{
minWidth: '44px',
minHeight: '44px',
padding: 1,
color: isSaved ? 'warning.main' : 'inherit'
}}
>
{isSaved ? <BookmarkIcon /> : <BookmarkBorderIcon />}
</IconButton>
</Box>
</Card>
);
};
export default StationCard;

View File

@@ -0,0 +1,186 @@
/**
* @ai-summary Google Maps component for station visualization
*/
import React, { useEffect, useRef, useState } from 'react';
import { Box, CircularProgress, Alert } from '@mui/material';
import { Station } from '../types/stations.types';
import { loadGoogleMaps, getGoogleMapsApi } from '../utils/maps-loader';
import {
createStationMarker,
createCurrentLocationMarker,
createInfoWindow,
fitBoundsToMarkers
} from '../utils/map-utils';
interface StationMapProps {
stations: Station[];
savedPlaceIds?: Set<string>;
center?: { lat: number; lng: number };
currentLocation?: { latitude: number; longitude: number };
zoom?: number;
onMarkerClick?: (station: Station) => void;
height?: string;
}
/**
* Google Maps component showing station markers
* Responsive height: 300px mobile, 500px desktop
* Blue markers for normal stations, gold for saved
*/
export const StationMap: React.FC<StationMapProps> = ({
stations,
savedPlaceIds = new Set(),
center,
currentLocation,
zoom = 12,
onMarkerClick,
height = '500px'
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const markers = useRef<google.maps.Marker[]>([]);
const infoWindows = useRef<google.maps.InfoWindow[]>([]);
const currentLocationMarker = useRef<google.maps.Marker | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Initialize map
useEffect(() => {
const initMap = async () => {
try {
setIsLoading(true);
// Load Google Maps API
await loadGoogleMaps();
const maps = getGoogleMapsApi();
if (!mapContainer.current) return;
// Create map
const defaultCenter = center || {
lat: currentLocation?.latitude || 37.7749,
lng: currentLocation?.longitude || -122.4194
};
map.current = new maps.Map(mapContainer.current, {
zoom,
center: defaultCenter,
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true
});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load map');
console.error('Map initialization failed:', err);
} finally {
setIsLoading(false);
}
};
initMap();
}, []);
// Update markers when stations or saved status changes
useEffect(() => {
if (!map.current) return;
// Clear old markers and info windows
markers.current.forEach((marker) => marker.setMap(null));
infoWindows.current.forEach((iw) => iw.close());
markers.current = [];
infoWindows.current = [];
getGoogleMapsApi();
let allMarkers: google.maps.Marker[] = [];
// Add station markers
stations.forEach((station) => {
const isSaved = savedPlaceIds.has(station.placeId);
const marker = createStationMarker(station, map.current!, isSaved);
const infoWindow = createInfoWindow(station, isSaved);
markers.current.push(marker);
infoWindows.current.push(infoWindow);
allMarkers.push(marker);
// Add click listener
marker.addListener('click', () => {
// Close all other info windows
infoWindows.current.forEach((iw) => iw.close());
// Open this one
infoWindow.open(map.current, marker);
onMarkerClick?.(station);
});
});
// Add current location marker
if (currentLocation) {
if (currentLocationMarker.current) {
currentLocationMarker.current.setMap(null);
}
currentLocationMarker.current = createCurrentLocationMarker(
currentLocation.latitude,
currentLocation.longitude,
map.current
);
allMarkers.push(currentLocationMarker.current);
}
// Fit bounds to show all markers
if (allMarkers.length > 0) {
fitBoundsToMarkers(map.current, allMarkers);
}
}, [stations, savedPlaceIds, currentLocation, onMarkerClick]);
if (error) {
return (
<Box
sx={{
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 2
}}
>
<Alert severity="error">{error}</Alert>
</Box>
);
}
return (
<Box
ref={mapContainer}
sx={{
height,
width: '100%',
position: 'relative',
borderRadius: 1,
overflow: 'hidden',
backgroundColor: '#e0e0e0'
}}
>
{isLoading && (
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 1
}}
>
<CircularProgress />
</Box>
)}
</Box>
);
};
export default StationMap;

View File

@@ -0,0 +1,105 @@
/**
* @ai-summary Grid list of stations from search results
*/
import React from 'react';
import {
Grid,
Box,
Typography,
Skeleton,
Alert,
Button
} from '@mui/material';
import { Station } from '../types/stations.types';
import StationCard from './StationCard';
interface StationsListProps {
stations: Station[];
savedPlaceIds?: Set<string>;
loading?: boolean;
error?: string | null;
onSaveStation?: (station: Station) => void;
onDeleteStation?: (placeId: string) => void;
onSelectStation?: (station: Station) => void;
onRetry?: () => void;
}
/**
* Responsive grid of station cards
* Layout: 1 col mobile, 2 cols tablet, 3 cols desktop
*/
export const StationsList: React.FC<StationsListProps> = ({
stations,
savedPlaceIds = new Set(),
loading = false,
error = null,
onSaveStation,
onDeleteStation,
onSelectStation,
onRetry
}) => {
// Loading state
if (loading) {
return (
<Grid container spacing={2}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<Grid item xs={12} sm={6} md={4} key={i}>
<Skeleton variant="rectangular" height={300} />
</Grid>
))}
</Grid>
);
}
// Error state
if (error) {
return (
<Box sx={{ padding: 2 }}>
<Alert severity="error">
<Typography variant="subtitle2">{error}</Typography>
{onRetry && (
<Button
variant="contained"
size="small"
onClick={onRetry}
sx={{ marginTop: 1 }}
>
Retry
</Button>
)}
</Alert>
</Box>
);
}
// Empty state
if (stations.length === 0) {
return (
<Box sx={{ textAlign: 'center', padding: 3 }}>
<Typography variant="body1" color="textSecondary">
No stations found. Try adjusting your search location or radius.
</Typography>
</Box>
);
}
// Stations grid
return (
<Grid container spacing={2}>
{stations.map((station) => (
<Grid item xs={12} sm={6} md={4} key={station.placeId}>
<StationCard
station={station}
isSaved={savedPlaceIds.has(station.placeId)}
onSave={onSaveStation}
onDelete={onDeleteStation}
onSelect={onSelectStation}
/>
</Grid>
))}
</Grid>
);
};
export default StationsList;

View File

@@ -0,0 +1,207 @@
/**
* @ai-summary Form for searching nearby gas stations
*/
import React, { useState, useEffect } from 'react';
import {
Box,
TextField,
Button,
Slider,
FormControl,
FormLabel,
Alert,
CircularProgress,
InputAdornment
} from '@mui/material';
import LocationIcon from '@mui/icons-material/LocationOn';
import MyLocationIcon from '@mui/icons-material/MyLocation';
import { StationSearchRequest, GeolocationError } from '../types/stations.types';
import { useGeolocation } from '../hooks';
interface StationsSearchFormProps {
onSearch: (request: StationSearchRequest) => void;
isSearching?: boolean;
}
/**
* Search form with manual location input and geolocation button
* Radius slider: 1-25 miles, default 5 miles
*/
export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
onSearch,
isSearching = false
}) => {
const [latitude, setLatitude] = useState<number | ''>('');
const [longitude, setLongitude] = useState<number | ''>('');
const [radius, setRadius] = useState(5); // Miles
const [locationError, setLocationError] = useState<string | null>(null);
const {
coordinates,
isPending: isGeolocating,
error: geoError,
requestPermission,
clearError: clearGeoError
} = useGeolocation();
// Update form when geolocation succeeds
useEffect(() => {
if (coordinates) {
setLatitude(coordinates.latitude);
setLongitude(coordinates.longitude);
setLocationError(null);
}
}, [coordinates]);
// Handle geolocation errors
useEffect(() => {
if (geoError) {
if (geoError === GeolocationError.PERMISSION_DENIED) {
setLocationError('Location permission denied. Please enable it in browser settings.');
} else if (geoError === GeolocationError.TIMEOUT) {
setLocationError('Location request timed out. Try again.');
} else if (geoError === GeolocationError.POSITION_UNAVAILABLE) {
setLocationError('Location not available. Try a different device.');
} else {
setLocationError('Unable to get location. Please enter manually.');
}
}
}, [geoError]);
const handleUseCurrentLocation = () => {
clearGeoError();
requestPermission();
};
const handleSearch = () => {
if (latitude === '' || longitude === '') {
setLocationError('Please enter coordinates or use current location');
return;
}
const request: StationSearchRequest = {
latitude: typeof latitude === 'number' ? latitude : 0,
longitude: typeof longitude === 'number' ? longitude : 0,
radius: radius * 1609.34 // Convert miles to meters
};
onSearch(request);
};
const handleRadiusChange = (
_event: Event,
newValue: number | number[]
) => {
if (typeof newValue === 'number') {
setRadius(newValue);
}
};
return (
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
handleSearch();
}}
sx={{ padding: 2, display: 'flex', flexDirection: 'column', gap: 2 }}
>
{/* Geolocation Button */}
<Button
variant="contained"
startIcon={isGeolocating ? <CircularProgress size={20} /> : <MyLocationIcon />}
onClick={handleUseCurrentLocation}
disabled={isGeolocating}
fullWidth
>
{isGeolocating ? 'Getting location...' : 'Use Current Location'}
</Button>
{/* Or Divider */}
<Box sx={{ textAlign: 'center', color: 'textSecondary' }}>or</Box>
{/* Manual Latitude Input */}
<TextField
label="Latitude"
type="number"
value={latitude}
onChange={(e) => {
const val = e.target.value;
setLatitude(val === '' ? '' : parseFloat(val));
}}
placeholder="37.7749"
inputProps={{ step: '0.0001', min: '-90', max: '90' }}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationIcon />
</InputAdornment>
)
}}
/>
{/* Manual Longitude Input */}
<TextField
label="Longitude"
type="number"
value={longitude}
onChange={(e) => {
const val = e.target.value;
setLongitude(val === '' ? '' : parseFloat(val));
}}
placeholder="-122.4194"
inputProps={{ step: '0.0001', min: '-180', max: '180' }}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationIcon />
</InputAdornment>
)
}}
/>
{/* Radius Slider */}
<FormControl fullWidth>
<FormLabel>Search Radius: {radius} mi</FormLabel>
<Slider
value={radius}
onChange={handleRadiusChange}
min={1}
max={25}
step={0.5}
marks={[
{ value: 1, label: '1 mi' },
{ value: 5, label: '5 mi' },
{ value: 10, label: '10 mi' },
{ value: 25, label: '25 mi' }
]}
sx={{ marginTop: 2, marginBottom: 1 }}
/>
</FormControl>
{/* Error Messages */}
{locationError && (
<Alert severity="error">{locationError}</Alert>
)}
{/* Search Button */}
<Button
variant="contained"
color="primary"
onClick={handleSearch}
disabled={isSearching || latitude === '' || longitude === ''}
sx={{
minHeight: '44px',
marginTop: 1
}}
>
{isSearching ? <CircularProgress size={24} /> : 'Search Stations'}
</Button>
</Box>
);
};
export default StationsSearchForm;

View File

@@ -0,0 +1,9 @@
/**
* @ai-summary Stations feature components exports
*/
export { StationCard } from './StationCard';
export { StationsList } from './StationsList';
export { SavedStationsList } from './SavedStationsList';
export { StationsSearchForm } from './StationsSearchForm';
export { StationMap } from './StationMap';

View File

@@ -0,0 +1,9 @@
/**
* @ai-summary Stations feature hooks exports
*/
export { useStationsSearch } from './useStationsSearch';
export { useSavedStations, useInvalidateSavedStations, useUpdateSavedStationsCache } from './useSavedStations';
export { useSaveStation } from './useSaveStation';
export { useDeleteStation } from './useDeleteStation';
export { useGeolocation } from './useGeolocation';

View File

@@ -0,0 +1,61 @@
/**
* @ai-summary Hook for deleting saved stations
*/
import { useMutation } from '@tanstack/react-query';
import { stationsApi } from '../api/stations.api';
import { SavedStation, ApiError } from '../types/stations.types';
import { useUpdateSavedStationsCache } from './useSavedStations';
interface UseDeleteStationOptions {
onSuccess?: (placeId: string) => void;
onError?: (error: ApiError) => void;
}
/**
* Mutation hook for deleting a saved station
* Includes optimistic removal from cache
*
* @example
* ```tsx
* const { mutate: deleteStation, isPending } = useDeleteStation();
*
* const handleDelete = (placeId: string) => {
* deleteStation(placeId);
* };
* ```
*/
export function useDeleteStation(options?: UseDeleteStationOptions) {
const updateCache = useUpdateSavedStationsCache();
return useMutation({
mutationFn: async (placeId: string) => {
await stationsApi.deleteSavedStation(placeId);
return placeId;
},
onMutate: async (placeId) => {
// Save previous state for rollback
let previousStations: SavedStation[] | undefined;
// Optimistic update: remove station immediately
updateCache((old) => {
previousStations = old;
if (!old) return [];
return old.filter((s) => s.placeId !== placeId);
});
return { previousStations, placeId };
},
onSuccess: (placeId) => {
options?.onSuccess?.(placeId);
},
onError: (error, _placeId, context) => {
// Rollback optimistic update on error
if (context?.previousStations) {
updateCache(() => context.previousStations || []);
}
options?.onError?.(error as ApiError);
}
});
}

View File

@@ -0,0 +1,168 @@
/**
* @ai-summary Hook for browser geolocation API
*/
import { useState, useCallback } from 'react';
import { GeolocationCoordinates, GeolocationError } from '../types/stations.types';
interface UseGeolocationState {
/** Current coordinates or null if not available */
coordinates: GeolocationCoordinates | null;
/** Whether location request is in progress */
isPending: boolean;
/** Error if geolocation failed */
error: GeolocationError | null;
/** Whether user has granted permission */
hasPermission: boolean;
}
interface UseGeolocationOptions {
/** Enable high accuracy (slower but more precise) */
enableHighAccuracy?: boolean;
/** Timeout in milliseconds */
timeout?: number;
/** Maximum cache age in milliseconds */
maximumAge?: number;
}
/**
* Hook for accessing browser geolocation API
* Handles permissions, errors, and provides methods to request location
*
* @example
* ```tsx
* const { coordinates, isPending, error, requestLocation } = useGeolocation();
*
* return (
* <div>
* {coordinates && (
* <p>Location: {coordinates.latitude}, {coordinates.longitude}</p>
* )}
* <button onClick={requestLocation} disabled={isPending}>
* Get Current Location
* </button>
* {error && <p>Error: {error}</p>}
* </div>
* );
* ```
*/
export function useGeolocation(options?: UseGeolocationOptions) {
const [state, setState] = useState<UseGeolocationState>({
coordinates: null,
isPending: false,
error: null,
hasPermission: true
});
// Request user's current location
const requestLocation = useCallback(() => {
if (!navigator?.geolocation) {
setState((prev) => ({
...prev,
error: GeolocationError.POSITION_UNAVAILABLE
}));
return;
}
setState((prev) => ({ ...prev, isPending: true, error: null }));
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords;
setState({
coordinates: { latitude, longitude, accuracy },
isPending: false,
error: null,
hasPermission: true
});
},
(error) => {
let geolocationError = GeolocationError.UNKNOWN;
switch (error.code) {
case error.PERMISSION_DENIED:
geolocationError = GeolocationError.PERMISSION_DENIED;
break;
case error.POSITION_UNAVAILABLE:
geolocationError = GeolocationError.POSITION_UNAVAILABLE;
break;
case error.TIMEOUT:
geolocationError = GeolocationError.TIMEOUT;
break;
}
setState({
coordinates: null,
isPending: false,
error: geolocationError,
hasPermission:
geolocationError !== GeolocationError.PERMISSION_DENIED
});
},
{
enableHighAccuracy: options?.enableHighAccuracy ?? false,
timeout: options?.timeout ?? 10000,
maximumAge: options?.maximumAge ?? 0
}
);
}, [options]);
// Request permission explicitly (iOS 13+)
const requestPermission = useCallback(async () => {
if (!navigator?.permissions?.query) {
// Permissions API not supported, fallback to direct request
requestLocation();
return;
}
try {
const permission = await navigator.permissions.query({
name: 'geolocation'
});
if (permission.state === 'granted') {
setState((prev) => ({ ...prev, hasPermission: true }));
requestLocation();
} else if (permission.state === 'prompt') {
requestLocation();
} else {
setState((prev) => ({
...prev,
error: GeolocationError.PERMISSION_DENIED,
hasPermission: false
}));
}
} catch (error) {
// Fallback: just request location
requestLocation();
}
}, [requestLocation]);
// Clear error
const clearError = useCallback(() => {
setState((prev) => ({ ...prev, error: null }));
}, []);
// Clear coordinates
const clearLocation = useCallback(() => {
setState((prev) => ({ ...prev, coordinates: null }));
}, []);
return {
...state,
requestLocation,
requestPermission,
clearError,
clearLocation,
/**
* Convenience method to get coordinates object
* Throws error if location not available
*/
getCoordinates: () => {
if (!state.coordinates) {
throw new Error('Location coordinates not available');
}
return state.coordinates;
}
};
}

View File

@@ -0,0 +1,100 @@
/**
* @ai-summary Hook for saving stations to favorites
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { stationsApi } from '../api/stations.api';
import { SavedStation, SaveStationData, ApiError } from '../types/stations.types';
import { useSavedStationsQueryKey, useUpdateSavedStationsCache } from './useSavedStations';
interface UseSaveStationOptions {
onSuccess?: (station: SavedStation) => void;
onError?: (error: ApiError) => void;
}
/**
* Mutation hook for saving a station to favorites
* Includes optimistic updates to saved stations cache
*
* @example
* ```tsx
* const { mutate: saveStation, isPending } = useSaveStation();
*
* const handleSave = (placeId: string) => {
* saveStation({
* placeId,
* nickname: 'My Station',
* isFavorite: true
* });
* };
* ```
*/
export function useSaveStation(options?: UseSaveStationOptions) {
const queryClient = useQueryClient();
const savedStationsKey = useSavedStationsQueryKey();
const updateCache = useUpdateSavedStationsCache();
return useMutation({
mutationFn: async ({
placeId,
data
}: {
placeId: string;
data: SaveStationData;
}) => {
return stationsApi.saveStation(placeId, data);
},
onMutate: async ({ placeId, data }) => {
// Optimistic update: add station to cache immediately
updateCache((old) => {
if (!old) return [];
const exists = old.some((s) => s.placeId === placeId);
if (exists) return old;
// Create optimistic station entry
const optimisticStation: SavedStation = {
id: `temp-${placeId}`,
placeId,
name: data.nickname || 'New Station',
address: '',
latitude: 0,
longitude: 0,
rating: 0,
userId: '', // Will be filled by server
nickname: data.nickname,
notes: data.notes,
isFavorite: data.isFavorite ?? false,
createdAt: new Date(),
updatedAt: new Date()
};
return [...old, optimisticStation];
});
// Return context for rollback
return { placeId };
},
onSuccess: (station) => {
// Invalidate query to fetch fresh data
queryClient.invalidateQueries({
queryKey: savedStationsKey
});
options?.onSuccess?.(station);
},
onError: (error, _variables, context) => {
// Rollback optimistic update on error
if (context?.placeId) {
updateCache((old) => {
if (!old) return [];
return old.filter(
(s) => s.placeId !== context.placeId || !s.id.startsWith('temp-')
);
});
}
options?.onError?.(error as ApiError);
}
});
}

View File

@@ -0,0 +1,76 @@
/**
* @ai-summary Hook for managing saved stations with caching
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { stationsApi } from '../api/stations.api';
import { SavedStation } from '../types/stations.types';
const SAVED_STATIONS_QUERY_KEY = ['stations', 'saved'];
interface UseSavedStationsOptions {
/** Auto-refetch when window regains focus */
refetchOnWindowFocus?: boolean;
/** Cache time in milliseconds (default: 5 minutes) */
staleTime?: number;
}
/**
* Query hook for user's saved stations
* Caches results and auto-refetches on window focus
*
* @example
* ```tsx
* const { data: savedStations, isPending, error } = useSavedStations();
*
* return (
* <div>
* {savedStations?.map(station => (
* <StationCard key={station.placeId} station={station} />
* ))}
* </div>
* );
* ```
*/
export function useSavedStations(options?: UseSavedStationsOptions) {
return useQuery({
queryKey: SAVED_STATIONS_QUERY_KEY,
queryFn: () => stationsApi.getSavedStations(),
staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes default
refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true,
refetchOnMount: true
});
}
/**
* Get query client utility for manual cache management
*/
export function useSavedStationsQueryKey() {
return SAVED_STATIONS_QUERY_KEY;
}
/**
* Invalidate saved stations cache
* Call after mutations that affect saved stations
*/
export function useInvalidateSavedStations() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({
queryKey: SAVED_STATIONS_QUERY_KEY
});
};
}
/**
* Update saved stations cache with optimistic update
* @param updater Function to update the cache data
*/
export function useUpdateSavedStationsCache() {
const queryClient = useQueryClient();
return (updater: (old: SavedStation[] | undefined) => SavedStation[]) => {
queryClient.setQueryData(SAVED_STATIONS_QUERY_KEY, updater);
};
}

View File

@@ -0,0 +1,44 @@
/**
* @ai-summary Hook for searching nearby gas stations
*/
import { useMutation } from '@tanstack/react-query';
import { stationsApi } from '../api/stations.api';
import { Station, StationSearchRequest, ApiError } from '../types/stations.types';
interface UseStationsSearchOptions {
onSuccess?: (stations: Station[]) => void;
onError?: (error: ApiError) => void;
}
/**
* Mutation hook for searching nearby stations
* Not cached by default - each search is independent
*
* @example
* ```tsx
* const { mutate: search, isPending, data } = useStationsSearch();
*
* const handleSearch = async () => {
* search({
* latitude: 37.7749,
* longitude: -122.4194,
* radius: 5000
* });
* };
* ```
*/
export function useStationsSearch(options?: UseStationsSearchOptions) {
return useMutation({
mutationFn: async (request: StationSearchRequest) => {
const stations = await stationsApi.searchStations(request);
return stations;
},
onSuccess: (data) => {
options?.onSuccess?.(data);
},
onError: (error) => {
options?.onError?.(error as ApiError);
}
});
}

View File

@@ -0,0 +1,385 @@
/**
* @ai-summary Mobile-optimized gas stations screen with bottom tab navigation
* @ai-context Three tabs: Search, Saved, Map with responsive mobile-first design
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Box,
BottomNavigation as MuiBottomNavigation,
BottomNavigationAction,
SwipeableDrawer,
Fab,
IconButton,
Typography,
Divider,
useTheme
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import MapIcon from '@mui/icons-material/Map';
import CloseIcon from '@mui/icons-material/Close';
import { StationsSearchForm } from '../components/StationsSearchForm';
import { StationsList } from '../components/StationsList';
import { SavedStationsList } from '../components/SavedStationsList';
import { StationMap } from '../components/StationMap';
import {
useStationsSearch,
useSavedStations,
useSaveStation,
useDeleteStation,
useGeolocation
} from '../hooks';
import {
Station,
SavedStation,
StationSearchRequest
} from '../types/stations.types';
// Tab indices
const TAB_SEARCH = 0;
const TAB_SAVED = 1;
const TAB_MAP = 2;
// iOS swipeable drawer configuration
const iOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
export const StationsMobileScreen: React.FC = () => {
const theme = useTheme();
// Tab state
const [activeTab, setActiveTab] = useState(TAB_SEARCH);
// Bottom sheet state
const [selectedStation, setSelectedStation] = useState<Station | SavedStation | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// Hooks
const { coordinates } = useGeolocation();
const {
mutate: performSearch,
data: searchResults,
isPending: isSearching,
error: searchError
} = useStationsSearch();
const {
data: savedStations,
isLoading: isLoadingSaved,
error: savedError
} = useSavedStations();
const { mutateAsync: saveStation } = useSaveStation();
const { mutateAsync: deleteStation } = useDeleteStation();
// Compute set of saved place IDs for quick lookup
const savedPlaceIds = useMemo(() => {
return new Set(savedStations?.map(s => s.placeId) || []);
}, [savedStations]);
// Handle search submission
const handleSearch = useCallback((request: StationSearchRequest) => {
performSearch(request);
}, [performSearch]);
// Handle station selection (opens bottom sheet)
const handleSelectStation = useCallback((station: Station | SavedStation) => {
setSelectedStation(station);
setDrawerOpen(true);
}, []);
// Handle save station
const handleSaveStation = useCallback(async (station: Station) => {
try {
await saveStation({
placeId: station.placeId,
data: {
nickname: station.name,
isFavorite: false
}
});
} catch (error) {
console.error('Failed to save station:', error);
}
}, [saveStation]);
// Handle delete station
const handleDeleteStation = useCallback(async (placeId: string) => {
try {
await deleteStation(placeId);
// Close drawer if currently viewing deleted station
if (selectedStation?.placeId === placeId) {
setDrawerOpen(false);
setSelectedStation(null);
}
} catch (error) {
console.error('Failed to delete station:', error);
}
}, [deleteStation, selectedStation]);
// Close bottom sheet
const handleCloseDrawer = useCallback(() => {
setDrawerOpen(false);
}, []);
// Navigate to search tab (from Map FAB)
const handleBackToSearch = useCallback(() => {
setActiveTab(TAB_SEARCH);
}, []);
// Tab change handler
const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
}, []);
// Pull-to-refresh handler (Search tab) - not implemented yet
const handleRefresh = useCallback(() => {
// TODO: Implement pull-to-refresh
}, []);
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
pb: 7, // Space for bottom navigation
overflow: 'hidden'
}}
>
{/* Tab content area */}
<Box
sx={{
flex: 1,
overflow: 'auto',
WebkitOverflowScrolling: 'touch'
}}
>
{/* Search Tab */}
{activeTab === TAB_SEARCH && (
<Box sx={{ p: 2 }}>
<StationsSearchForm
onSearch={handleSearch}
isSearching={isSearching}
/>
{searchResults && (
<Box sx={{ mt: 3 }}>
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
loading={isSearching}
error={searchError ? 'Failed to search stations' : null}
onSaveStation={handleSaveStation}
onDeleteStation={handleDeleteStation}
onSelectStation={handleSelectStation}
onRetry={handleRefresh}
/>
</Box>
)}
</Box>
)}
{/* Saved Tab */}
{activeTab === TAB_SAVED && (
<Box sx={{ height: '100%' }}>
<SavedStationsList
stations={savedStations || []}
loading={isLoadingSaved}
error={savedError ? 'Failed to load saved stations' : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDeleteStation}
/>
</Box>
)}
{/* Map Tab */}
{activeTab === TAB_MAP && (
<Box sx={{ height: '100%', position: 'relative' }}>
<StationMap
stations={searchResults || []}
savedPlaceIds={savedPlaceIds}
currentLocation={coordinates ? {
latitude: coordinates.latitude,
longitude: coordinates.longitude
} : undefined}
onMarkerClick={handleSelectStation}
height="100%"
/>
{/* FAB to go back to search */}
<Fab
color="primary"
size="medium"
sx={{
position: 'absolute',
bottom: 16,
right: 16,
minWidth: '44px',
minHeight: '44px'
}}
onClick={handleBackToSearch}
aria-label="Back to search"
>
<SearchIcon />
</Fab>
</Box>
)}
</Box>
{/* Bottom Navigation */}
<MuiBottomNavigation
value={activeTab}
onChange={handleTabChange}
showLabels
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
borderTop: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
height: 56,
zIndex: theme.zIndex.appBar
}}
>
<BottomNavigationAction
label="Search"
icon={<SearchIcon />}
sx={{
minWidth: '44px',
minHeight: '44px'
}}
/>
<BottomNavigationAction
label="Saved"
icon={<BookmarkIcon />}
sx={{
minWidth: '44px',
minHeight: '44px'
}}
/>
<BottomNavigationAction
label="Map"
icon={<MapIcon />}
sx={{
minWidth: '44px',
minHeight: '44px'
}}
/>
</MuiBottomNavigation>
{/* Bottom Sheet for Station Details */}
<SwipeableDrawer
anchor="bottom"
open={drawerOpen}
onClose={handleCloseDrawer}
onOpen={() => setDrawerOpen(true)}
disableBackdropTransition={!iOS}
disableDiscovery={iOS}
sx={{
'& .MuiDrawer-paper': {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80vh',
overflow: 'visible'
}
}}
>
{selectedStation && (
<Box sx={{ p: 2, pb: 4 }}>
{/* Drawer handle */}
<Box
sx={{
width: 40,
height: 4,
backgroundColor: theme.palette.divider,
borderRadius: 2,
margin: '0 auto 16px'
}}
/>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{'nickname' in selectedStation && selectedStation.nickname
? selectedStation.nickname
: selectedStation.name}
</Typography>
{'nickname' in selectedStation && selectedStation.nickname && (
<Typography variant="body2" color="text.secondary">
{selectedStation.name}
</Typography>
)}
</Box>
<IconButton
onClick={handleCloseDrawer}
sx={{
minWidth: '44px',
minHeight: '44px'
}}
aria-label="Close"
>
<CloseIcon />
</IconButton>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Station Details */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography variant="caption" color="text.secondary">
Address
</Typography>
<Typography variant="body1">
{selectedStation.address}
</Typography>
</Box>
{selectedStation.rating > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Rating
</Typography>
<Typography variant="body1">
{selectedStation.rating.toFixed(1)} / 5.0
</Typography>
</Box>
)}
{selectedStation.distance !== undefined && (
<Box>
<Typography variant="caption" color="text.secondary">
Distance
</Typography>
<Typography variant="body1">
{(selectedStation.distance / 1609.34).toFixed(1)} miles away
</Typography>
</Box>
)}
{'notes' in selectedStation && selectedStation.notes && (
<Box>
<Typography variant="caption" color="text.secondary">
Notes
</Typography>
<Typography variant="body1">
{selectedStation.notes}
</Typography>
</Box>
)}
</Box>
</Box>
)}
</SwipeableDrawer>
</Box>
);
};
export default StationsMobileScreen;

View File

@@ -0,0 +1,251 @@
/**
* @ai-summary Desktop stations page with map and list layout
*/
import React, { useState, useMemo } from 'react';
import {
Grid,
Paper,
Tabs,
Tab,
Box,
Alert,
useMediaQuery,
useTheme
} from '@mui/material';
import { Station, StationSearchRequest } from '../types/stations.types';
import {
useStationsSearch,
useSavedStations,
useSaveStation,
useDeleteStation
} from '../hooks';
import {
StationMap,
StationsList,
SavedStationsList,
StationsSearchForm
} from '../components';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
return (
<div
role="tabpanel"
hidden={value !== index}
id={`stations-tabpanel-${index}`}
aria-labelledby={`stations-tab-${index}`}
>
{value === index && <Box sx={{ padding: 2 }}>{children}</Box>}
</div>
);
};
/**
* Desktop stations page layout
* Left: Map (60%), Right: Search form + Tabs (40%)
* Mobile: Stacks vertically
*/
export const StationsPage: React.FC = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [tabValue, setTabValue] = useState(0);
const [searchResults, setSearchResults] = useState<Station[]>([]);
const [mapCenter, setMapCenter] = useState<{ lat: number; lng: number } | null>(null);
const [currentLocation, setCurrentLocation] = useState<
{ latitude: number; longitude: number } | undefined
>();
// Queries and mutations
const { mutate: search, isPending: isSearching, error: searchError } = useStationsSearch();
const { data: savedStations = [] } = useSavedStations();
const { mutate: saveStation } = useSaveStation();
const { mutate: deleteStation } = useDeleteStation();
// Create set of saved place IDs for quick lookup
const savedPlaceIds = useMemo(
() => new Set(savedStations.map((s) => s.placeId)),
[savedStations]
);
// Handle search
const handleSearch = (request: StationSearchRequest) => {
setCurrentLocation({ latitude: request.latitude, longitude: request.longitude });
setMapCenter({ lat: request.latitude, lng: request.longitude });
search(request, {
onSuccess: (stations) => {
setSearchResults(stations);
setTabValue(0); // Switch to results tab
}
});
};
// Handle save station
const handleSave = (station: Station) => {
saveStation(
{
placeId: station.placeId,
data: { isFavorite: true }
},
{
onSuccess: () => {
setSearchResults((prev) =>
prev.map((s) =>
s.placeId === station.placeId ? { ...s } : s
)
);
}
}
);
};
// Handle delete station
const handleDelete = (placeId: string) => {
deleteStation(placeId);
};
// If mobile, stack components vertically
if (isMobile) {
return (
<Box sx={{ padding: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Paper>
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
</Paper>
{searchError && (
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
)}
<StationMap
stations={searchResults}
savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation}
center={mapCenter || undefined}
height="300px"
/>
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
indicatorColor="primary"
textColor="primary"
aria-label="stations tabs"
>
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
onDeleteStation={handleDelete}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<SavedStationsList
stations={savedStations}
onSelectStation={(station) => {
setMapCenter({
lat: station.latitude,
lng: station.longitude
});
}}
onDeleteStation={handleDelete}
/>
</TabPanel>
</Box>
);
}
// Desktop layout: side-by-side
return (
<Grid container spacing={2} sx={{ padding: 2, height: 'calc(100vh - 80px)' }}>
{/* Left: Map (60%) */}
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
<Paper sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<StationMap
stations={searchResults}
savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation}
center={mapCenter || undefined}
height="100%"
/>
</Paper>
</Grid>
{/* Right: Search + Tabs (40%) */}
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Search Form */}
<Paper>
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
</Paper>
{/* Error Alert */}
{searchError && (
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
)}
{/* Tabs */}
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
indicatorColor="primary"
textColor="primary"
aria-label="stations tabs"
>
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
</Tabs>
{/* Tab Content with overflow */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
onDeleteStation={handleDelete}
onSelectStation={(station) => {
setMapCenter({
lat: station.latitude,
lng: station.longitude
});
}}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<SavedStationsList
stations={savedStations}
onSelectStation={(station) => {
setMapCenter({
lat: station.latitude,
lng: station.longitude
});
}}
onDeleteStation={handleDelete}
/>
</TabPanel>
</Box>
</Paper>
</Grid>
</Grid>
);
};
export default StationsPage;

View File

@@ -0,0 +1,148 @@
/**
* Type declarations for Google Maps API
* These define the global google.maps namespace when the API is loaded dynamically
*/
declare global {
namespace google {
namespace maps {
/**
* Google Maps Map instance
*/
class Map {
constructor(
container: HTMLElement | null,
options?: google.maps.MapOptions
);
setCenter(latlng: google.maps.LatLng | google.maps.LatLngLiteral): void;
setZoom(zoom: number): void;
fitBounds(
bounds: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral,
padding?: number | { top: number; right: number; bottom: number; left: number }
): void;
getCenter(): google.maps.LatLng | undefined;
getZoom(): number | undefined;
}
/**
* Google Maps Marker
*/
class Marker {
constructor(options?: google.maps.MarkerOptions);
setMap(map: google.maps.Map | null): void;
setPosition(latlng: google.maps.LatLng | google.maps.LatLngLiteral): void;
getPosition(): google.maps.LatLng | undefined;
setTitle(title: string): void;
addListener(
eventName: string,
callback: (...args: any[]) => void
): google.maps.MapsEventListener;
}
/**
* Google Maps InfoWindow
*/
class InfoWindow {
constructor(options?: google.maps.InfoWindowOptions);
open(map?: google.maps.Map | null, anchor?: google.maps.Marker): void;
close(): void;
setContent(content: string | HTMLElement): void;
}
/**
* Google Maps LatLng
*/
class LatLng {
constructor(lat: number, lng: number);
lat(): number;
lng(): number;
}
/**
* Google Maps LatLngBounds
*/
class LatLngBounds {
constructor(sw?: google.maps.LatLng, ne?: google.maps.LatLng);
extend(point: google.maps.LatLng): google.maps.LatLngBounds;
}
/**
* Google Maps Symbol Path enum
*/
enum SymbolPath {
CIRCLE = 'CIRCLE',
FORWARD_CLOSED_ARROW = 'FORWARD_CLOSED_ARROW',
FORWARD_OPEN_ARROW = 'FORWARD_OPEN_ARROW',
BACKWARD_CLOSED_ARROW = 'BACKWARD_CLOSED_ARROW',
BACKWARD_OPEN_ARROW = 'BACKWARD_OPEN_ARROW'
}
/**
* Google Maps Event Listener
*/
interface MapsEventListener {
remove(): void;
}
/**
* Map Options
*/
interface MapOptions {
zoom?: number;
center?: google.maps.LatLng | google.maps.LatLngLiteral;
mapTypeId?: string;
[key: string]: any;
}
/**
* Marker Options
*/
interface MarkerOptions {
position?: google.maps.LatLng | google.maps.LatLngLiteral;
map?: google.maps.Map;
title?: string;
icon?: string | google.maps.Icon;
[key: string]: any;
}
/**
* Info Window Options
*/
interface InfoWindowOptions {
content?: string | HTMLElement;
position?: google.maps.LatLng | google.maps.LatLngLiteral;
maxWidth?: number;
[key: string]: any;
}
/**
* Icon options
*/
interface Icon {
url?: string;
size?: { width: number; height: number };
[key: string]: any;
}
/**
* LatLng Literal
*/
interface LatLngLiteral {
lat: number;
lng: number;
}
/**
* LatLngBounds Literal
*/
interface LatLngBoundsLiteral {
east: number;
north: number;
south: number;
west: number;
}
}
}
}
export {};

View File

@@ -0,0 +1,139 @@
/**
* @ai-summary Type definitions for Gas Stations feature
*/
/**
* Geographic location coordinates
*/
export interface Location {
latitude: number;
longitude: number;
}
/**
* Gas station search request parameters
*/
export interface StationSearchRequest extends Location {
/** Search radius in meters (default: 5000 = 5km) */
radius?: number;
/** Optional fuel type filter */
fuelType?: string;
}
/**
* Station search response metadata
*/
export interface SearchLocation {
latitude: number;
longitude: number;
}
/**
* Single gas station from search results
*/
export interface Station {
/** Google Places ID for the station */
placeId: string;
/** Station name (e.g. "Shell Downtown") */
name: string;
/** Full address of the station */
address: string;
/** Formatted address from Google Maps */
formattedAddress?: string;
/** Latitude coordinate */
latitude: number;
/** Longitude coordinate */
longitude: number;
/** Google rating (0-5 stars) */
rating: number;
/** Distance from search location in meters */
distance?: number;
/** URL to station photo if available */
photoUrl?: string;
}
/**
* Saved/favorited station with user metadata
*/
export interface SavedStation extends Station {
/** Database record ID */
id: string;
/** User ID who saved the station */
userId: string;
/** Custom nickname given by user */
nickname?: string;
/** User notes about the station */
notes?: string;
/** Whether station is marked as favorite */
isFavorite: boolean;
/** Created timestamp */
createdAt: Date;
/** Last updated timestamp */
updatedAt: Date;
}
/**
* Station search response with metadata
*/
export interface StationSearchResponse {
/** Array of stations found */
stations: Station[];
/** Location where search was performed */
searchLocation: SearchLocation;
/** Radius used for search in meters */
searchRadius: number;
/** When search was performed */
timestamp: string;
}
/**
* Data needed to save a station
*/
export interface SaveStationData {
/** Custom nickname for the station */
nickname?: string;
/** User notes about the station */
notes?: string;
/** Whether to mark as favorite */
isFavorite?: boolean;
}
/**
* Marker for map display
*/
export interface MapMarker {
placeId: string;
name: string;
latitude: number;
longitude: number;
isSaved: boolean;
distance?: number;
}
/**
* Geolocation position from browser API
*/
export interface GeolocationCoordinates {
latitude: number;
longitude: number;
accuracy: number;
}
/**
* Geolocation status and error types
*/
export enum GeolocationError {
PERMISSION_DENIED = 'PERMISSION_DENIED',
POSITION_UNAVAILABLE = 'POSITION_UNAVAILABLE',
TIMEOUT = 'TIMEOUT',
UNKNOWN = 'UNKNOWN'
}
/**
* API error response
*/
export interface ApiError {
message: string;
code?: string;
details?: Record<string, unknown>;
}

View File

@@ -0,0 +1,73 @@
/**
* @ai-summary Distance calculation and formatting utilities
*/
/**
* Calculate distance between two coordinates using Haversine formula
* Returns distance in meters
*
* @param lat1 Starting latitude
* @param lon1 Starting longitude
* @param lat2 Ending latitude
* @param lon2 Ending longitude
* @returns Distance in meters
*/
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371000; // Earth's radius in meters
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return distance;
}
/**
* Format distance for display
* Shows miles if distance > 1609m, otherwise shows meters
*
* @param meters Distance in meters
* @returns Formatted distance string (e.g., "1.2 mi" or "500 m")
*/
export function formatDistance(meters: number): string {
const METERS_PER_MILE = 1609.34;
if (meters >= METERS_PER_MILE) {
const miles = meters / METERS_PER_MILE;
return `${miles.toFixed(1)} mi`;
}
return `${Math.round(meters)} m`;
}
/**
* Convert degrees to radians
*/
function toRad(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/**
* Convert miles to meters
*/
export function milesToMeters(miles: number): number {
return miles * 1609.34;
}
/**
* Convert meters to miles
*/
export function metersToMiles(meters: number): number {
return meters / 1609.34;
}

View File

@@ -0,0 +1,170 @@
/**
* @ai-summary Google Maps utility functions
*/
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
*/
export function createStationMarker(
station: Station,
map: google.maps.Map,
isSaved: boolean
): google.maps.Marker {
const maps = getGoogleMapsApi();
const markerColor = isSaved ? '#FFD700' : '#4285F4'; // Gold for saved, blue for normal
const marker = new maps.Marker({
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
}
});
// 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
): google.maps.InfoWindow {
const maps = getGoogleMapsApi();
const distanceText = station.distance
? `Distance: ${formatDistance(station.distance)}`
: '';
const content = `
<div style="font-family: Roboto, sans-serif; padding: 8px;">
<h3 style="margin: 0 0 4px 0; font-size: 16px;">${station.name}</h3>
<p style="margin: 4px 0; font-size: 12px; color: #666;">${station.address}</p>
${
distanceText
? `<p style="margin: 4px 0; font-size: 12px; color: #666;">${distanceText}</p>`
: ''
}
${
station.rating
? `<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;">
Directions
</a>
${isSaved ? '<span style="color: #FFD700; font-size: 12px;">★ Saved</span>' : ''}
</div>
</div>
`;
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[]
): void {
if (markers.length === 0) return;
const maps = getGoogleMapsApi();
const bounds = new maps.LatLngBounds();
markers.forEach((marker) => {
const position = marker.getPosition();
if (position) {
bounds.extend(position);
}
});
map.fitBounds(bounds);
// Add padding
const padding = { top: 50, right: 50, bottom: 50, left: 50 };
map.fitBounds(bounds, padding);
}
/**
* 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 {
const maps = getGoogleMapsApi();
return new maps.Marker({
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
}
});
}
/**
* Convert Station to MapMarker
*/
export function stationToMapMarker(
station: Station,
isSaved: boolean
): MapMarker {
return {
placeId: station.placeId,
name: station.name,
latitude: station.latitude,
longitude: station.longitude,
isSaved,
distance: station.distance
};
}

View File

@@ -0,0 +1,86 @@
/**
* @ai-summary Google Maps JavaScript API loader
* Handles dynamic loading and singleton pattern
*/
import { getGoogleMapsApiKey } from '@/core/config/config.types';
let mapsPromise: Promise<void> | null = null;
/**
* Load Google Maps JavaScript API dynamically
* Uses singleton pattern - only loads once
*
* @returns Promise that resolves when Google Maps is loaded
*/
export function loadGoogleMaps(): Promise<void> {
// Return cached promise if already loading/loaded
if (mapsPromise) {
return mapsPromise;
}
// Create loading promise
mapsPromise = new Promise((resolve, reject) => {
// Check if already loaded in window
if ((window as any).google?.maps) {
resolve();
return;
}
// Get API key from runtime config
const apiKey = getGoogleMapsApiKey();
if (!apiKey) {
reject(new Error('Google Maps API key is not configured'));
return;
}
// Create script tag
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
script.async = true;
script.defer = true;
script.onload = () => {
if ((window as any).google?.maps) {
resolve();
} else {
reject(
new Error('Google Maps loaded but window.google.maps not available')
);
}
};
script.onerror = () => {
// Reset promise so retry is possible
mapsPromise = null;
reject(new Error('Failed to load Google Maps script'));
};
// Add to document
document.head.appendChild(script);
});
return mapsPromise;
}
/**
* Get Google Maps API (after loading)
*
* @returns Google Maps object
* @throws Error if Google Maps not loaded
*/
export function getGoogleMapsApi(): typeof google.maps {
if (!(window as any).google?.maps) {
throw new Error('Google Maps not loaded. Call loadGoogleMaps() first.');
}
return (window as any).google.maps;
}
/**
* Reset loader (for testing)
*/
export function resetMapsLoader(): void {
mapsPromise = null;
}