Google Maps Bug
This commit is contained in:
@@ -51,6 +51,34 @@ export class CacheService {
|
|||||||
logger.error('Cache delete error', { key, error });
|
logger.error('Cache delete error', { key, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all keys matching the provided pattern (without namespace prefix).
|
||||||
|
* Uses SCAN to avoid blocking Redis for large keyspaces.
|
||||||
|
*/
|
||||||
|
async deletePattern(pattern: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const namespacedPattern = this.prefix + pattern;
|
||||||
|
let cursor = '0';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await redis.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
namespacedPattern,
|
||||||
|
'COUNT',
|
||||||
|
100
|
||||||
|
);
|
||||||
|
cursor = nextCursor;
|
||||||
|
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await redis.del(...keys);
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Cache delete pattern error', { pattern, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cacheService = new CacheService();
|
export const cacheService = new CacheService();
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ describe('Admin Catalog Integration Tests', () => {
|
|||||||
it('should invalidate cache after create operation', async () => {
|
it('should invalidate cache after create operation', async () => {
|
||||||
// Set a cache value
|
// Set a cache value
|
||||||
await redis.set('mvp:platform:vehicle-data:makes:2024', JSON.stringify([]), 3600);
|
await redis.set('mvp:platform:vehicle-data:makes:2024', JSON.stringify([]), 3600);
|
||||||
|
await redis.set('mvp:platform:years', JSON.stringify([2024]), 3600);
|
||||||
|
|
||||||
// Create make (should invalidate cache)
|
// Create make (should invalidate cache)
|
||||||
await app.inject({
|
await app.inject({
|
||||||
@@ -324,12 +325,11 @@ describe('Admin Catalog Integration Tests', () => {
|
|||||||
payload: { name: 'Honda' }
|
payload: { name: 'Honda' }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if cache was invalidated (implementation depends on invalidateVehicleData)
|
|
||||||
// Note: Current implementation logs warning but doesn't actually invalidate
|
|
||||||
// This test documents expected behavior
|
|
||||||
const cacheValue = await redis.get('mvp:platform:vehicle-data:makes:2024');
|
const cacheValue = await redis.get('mvp:platform:vehicle-data:makes:2024');
|
||||||
// Cache should be invalidated or remain (depending on implementation)
|
const yearsCacheValue = await redis.get('mvp:platform:years');
|
||||||
expect(cacheValue).toBeDefined();
|
|
||||||
|
expect(cacheValue).toBeNull();
|
||||||
|
expect(yearsCacheValue).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ export class PlatformCacheService {
|
|||||||
* Invalidate all vehicle data cache (for admin operations)
|
* Invalidate all vehicle data cache (for admin operations)
|
||||||
*/
|
*/
|
||||||
async invalidateVehicleData(): Promise<void> {
|
async invalidateVehicleData(): Promise<void> {
|
||||||
logger.warn('Vehicle data cache invalidation not implemented (requires pattern deletion)');
|
try {
|
||||||
|
const yearsKey = this.prefix + 'years';
|
||||||
|
await Promise.all([
|
||||||
|
this.cacheService.del(yearsKey),
|
||||||
|
this.cacheService.deletePattern(this.prefix + 'vehicle-data:*')
|
||||||
|
]);
|
||||||
|
logger.debug('Vehicle data cache invalidated');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Vehicle data cache invalidation failed', { error });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import { StationsService } from '../domain/stations.service';
|
|||||||
import { StationsRepository } from '../data/stations.repository';
|
import { StationsRepository } from '../data/stations.repository';
|
||||||
import { pool } from '../../../core/config/database';
|
import { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import { StationSearchBody, SaveStationBody, StationParams } from '../domain/stations.types';
|
import {
|
||||||
|
StationSearchBody,
|
||||||
|
SaveStationBody,
|
||||||
|
StationParams,
|
||||||
|
UpdateSavedStationBody
|
||||||
|
} from '../domain/stations.types';
|
||||||
|
|
||||||
export class StationsController {
|
export class StationsController {
|
||||||
private stationsService: StationsService;
|
private stationsService: StationsService;
|
||||||
@@ -50,7 +55,14 @@ export class StationsController {
|
|||||||
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = (request as any).user.sub;
|
||||||
const { placeId, nickname, notes, isFavorite } = request.body;
|
const {
|
||||||
|
placeId,
|
||||||
|
nickname,
|
||||||
|
notes,
|
||||||
|
isFavorite,
|
||||||
|
has93Octane,
|
||||||
|
has93OctaneEthanolFree
|
||||||
|
} = request.body;
|
||||||
|
|
||||||
if (!placeId) {
|
if (!placeId) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
@@ -62,7 +74,9 @@ export class StationsController {
|
|||||||
const result = await this.stationsService.saveStation(placeId, userId, {
|
const result = await this.stationsService.saveStation(placeId, userId, {
|
||||||
nickname,
|
nickname,
|
||||||
notes,
|
notes,
|
||||||
isFavorite
|
isFavorite,
|
||||||
|
has93Octane,
|
||||||
|
has93OctaneEthanolFree
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(201).send(result);
|
return reply.code(201).send(result);
|
||||||
@@ -82,6 +96,38 @@ export class StationsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSavedStation(
|
||||||
|
request: FastifyRequest<{ Params: StationParams; Body: UpdateSavedStationBody }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const userId = (request as any).user.sub;
|
||||||
|
const { placeId } = request.params;
|
||||||
|
|
||||||
|
const result = await this.stationsService.updateSavedStation(placeId, userId, request.body);
|
||||||
|
|
||||||
|
return reply.code(200).send(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error updating saved station', {
|
||||||
|
error,
|
||||||
|
placeId: request.params.placeId,
|
||||||
|
userId: (request as any).user?.sub
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.message.includes('not found')) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: 'Failed to update saved station'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
|
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
@@ -122,4 +168,4 @@ export class StationsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { FastifyPluginAsync } from 'fastify';
|
|||||||
import {
|
import {
|
||||||
StationSearchBody,
|
StationSearchBody,
|
||||||
SaveStationBody,
|
SaveStationBody,
|
||||||
StationParams
|
StationParams,
|
||||||
|
UpdateSavedStationBody
|
||||||
} from '../domain/stations.types';
|
} from '../domain/stations.types';
|
||||||
import { StationsController } from './stations.controller';
|
import { StationsController } from './stations.controller';
|
||||||
|
|
||||||
@@ -30,6 +31,15 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
|||||||
handler: stationsController.saveStation.bind(stationsController)
|
handler: stationsController.saveStation.bind(stationsController)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH /api/stations/saved/:placeId - Update saved station metadata
|
||||||
|
fastify.patch<{ Params: StationParams; Body: UpdateSavedStationBody }>(
|
||||||
|
'/stations/saved/:placeId',
|
||||||
|
{
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
handler: stationsController.updateSavedStation.bind(stationsController)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/stations/saved - Get user's saved stations
|
// GET /api/stations/saved - Get user's saved stations
|
||||||
fastify.get('/stations/saved', {
|
fastify.get('/stations/saved', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
|
|||||||
@@ -45,14 +45,37 @@ export class StationsRepository {
|
|||||||
return this.mapCacheRow(result.rows[0]);
|
return this.mapCacheRow(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveStation(userId: string, placeId: string, data?: { nickname?: string; notes?: string; isFavorite?: boolean }): Promise<SavedStation> {
|
async saveStation(
|
||||||
|
userId: string,
|
||||||
|
placeId: string,
|
||||||
|
data?: {
|
||||||
|
nickname?: string;
|
||||||
|
notes?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
has93Octane?: boolean;
|
||||||
|
has93OctaneEthanolFree?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<SavedStation> {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO saved_stations (user_id, place_id, nickname, notes, is_favorite)
|
INSERT INTO saved_stations (
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
user_id,
|
||||||
|
place_id,
|
||||||
|
nickname,
|
||||||
|
notes,
|
||||||
|
is_favorite,
|
||||||
|
has_93_octane,
|
||||||
|
has_93_octane_ethanol_free
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (user_id, place_id) DO UPDATE
|
ON CONFLICT (user_id, place_id) DO UPDATE
|
||||||
SET nickname = COALESCE($3, saved_stations.nickname),
|
SET nickname = COALESCE($3, saved_stations.nickname),
|
||||||
notes = COALESCE($4, saved_stations.notes),
|
notes = COALESCE($4, saved_stations.notes),
|
||||||
is_favorite = COALESCE($5, saved_stations.is_favorite),
|
is_favorite = COALESCE($5, saved_stations.is_favorite),
|
||||||
|
has_93_octane = COALESCE($6, saved_stations.has_93_octane),
|
||||||
|
has_93_octane_ethanol_free = CASE
|
||||||
|
WHEN $6 IS NOT NULL AND $6 = false THEN false
|
||||||
|
ELSE COALESCE($7, saved_stations.has_93_octane_ethanol_free)
|
||||||
|
END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
@@ -62,12 +85,58 @@ export class StationsRepository {
|
|||||||
placeId,
|
placeId,
|
||||||
data?.nickname,
|
data?.nickname,
|
||||||
data?.notes,
|
data?.notes,
|
||||||
data?.isFavorite || false
|
data?.isFavorite ?? false,
|
||||||
|
data?.has93Octane ?? false,
|
||||||
|
data?.has93OctaneEthanolFree ?? false
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.mapSavedRow(result.rows[0]);
|
return this.mapSavedRow(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSavedStation(
|
||||||
|
userId: string,
|
||||||
|
placeId: string,
|
||||||
|
data: {
|
||||||
|
nickname?: string;
|
||||||
|
notes?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
has93Octane?: boolean;
|
||||||
|
has93OctaneEthanolFree?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<SavedStation | null> {
|
||||||
|
const query = `
|
||||||
|
UPDATE saved_stations
|
||||||
|
SET
|
||||||
|
nickname = COALESCE($3, nickname),
|
||||||
|
notes = COALESCE($4, notes),
|
||||||
|
is_favorite = COALESCE($5, is_favorite),
|
||||||
|
has_93_octane = COALESCE($6, has_93_octane),
|
||||||
|
has_93_octane_ethanol_free = CASE
|
||||||
|
WHEN $6 IS NOT NULL AND $6 = false THEN false
|
||||||
|
ELSE COALESCE($7, has_93_octane_ethanol_free)
|
||||||
|
END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $1 AND place_id = $2
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.pool.query(query, [
|
||||||
|
userId,
|
||||||
|
placeId,
|
||||||
|
data.nickname,
|
||||||
|
data.notes,
|
||||||
|
data.isFavorite,
|
||||||
|
data.has93Octane,
|
||||||
|
data.has93OctaneEthanolFree
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapSavedRow(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
async getUserSavedStations(userId: string): Promise<SavedStation[]> {
|
async getUserSavedStations(userId: string): Promise<SavedStation[]> {
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM saved_stations
|
SELECT * FROM saved_stations
|
||||||
@@ -107,11 +176,14 @@ export class StationsRepository {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
stationId: row.place_id,
|
stationId: row.place_id,
|
||||||
|
placeId: row.place_id,
|
||||||
nickname: row.nickname,
|
nickname: row.nickname,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
isFavorite: row.is_favorite,
|
isFavorite: row.is_favorite,
|
||||||
|
has93Octane: row.has_93_octane ?? false,
|
||||||
|
has93OctaneEthanolFree: row.has_93_octane_ethanol_free ?? false,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
|
|
||||||
import { StationsRepository } from '../data/stations.repository';
|
import { StationsRepository } from '../data/stations.repository';
|
||||||
import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
||||||
import { StationSearchRequest, StationSearchResponse, SavedStation } from './stations.types';
|
import {
|
||||||
|
StationSearchRequest,
|
||||||
|
StationSearchResponse,
|
||||||
|
SavedStation,
|
||||||
|
StationSavedMetadata,
|
||||||
|
UpdateSavedStationBody
|
||||||
|
} from './stations.types';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
|
||||||
export class StationsService {
|
export class StationsService {
|
||||||
@@ -22,14 +28,43 @@ export class StationsService {
|
|||||||
request.longitude,
|
request.longitude,
|
||||||
request.radius || 5000
|
request.radius || 5000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch saved stations for prioritization and metadata
|
||||||
|
const savedStations = await this.repository.getUserSavedStations(userId);
|
||||||
|
const savedMap = new Map(savedStations.map(saved => [saved.placeId, saved]));
|
||||||
|
|
||||||
// Cache stations for future reference
|
// Cache stations for future reference
|
||||||
for (const station of stations) {
|
for (const station of stations) {
|
||||||
await this.repository.cacheStation(station);
|
await this.repository.cacheStation(station);
|
||||||
|
|
||||||
|
// Enrich station with saved metadata
|
||||||
|
const saved = savedMap.get(station.placeId);
|
||||||
|
if (saved) {
|
||||||
|
station.isSaved = true;
|
||||||
|
station.savedMetadata = this.mapSavedMetadata(saved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by distance
|
// Sort saved stations first, favorites next, then distance
|
||||||
stations.sort((a, b) => (a.distance || 0) - (b.distance || 0));
|
stations.sort((a, b) => {
|
||||||
|
const aSaved = a.savedMetadata ? 1 : 0;
|
||||||
|
const bSaved = b.savedMetadata ? 1 : 0;
|
||||||
|
|
||||||
|
if (aSaved !== bSaved) {
|
||||||
|
return bSaved - aSaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aFavorite = a.savedMetadata?.isFavorite ? 1 : 0;
|
||||||
|
const bFavorite = b.savedMetadata?.isFavorite ? 1 : 0;
|
||||||
|
|
||||||
|
if (aFavorite !== bFavorite) {
|
||||||
|
return bFavorite - aFavorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aDistance = a.distance ?? Number.POSITIVE_INFINITY;
|
||||||
|
const bDistance = b.distance ?? Number.POSITIVE_INFINITY;
|
||||||
|
return aDistance - bDistance;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stations,
|
stations,
|
||||||
@@ -45,7 +80,13 @@ export class StationsService {
|
|||||||
async saveStation(
|
async saveStation(
|
||||||
placeId: string,
|
placeId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
data?: { nickname?: string; notes?: string; isFavorite?: boolean }
|
data?: {
|
||||||
|
nickname?: string;
|
||||||
|
notes?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
has93Octane?: boolean;
|
||||||
|
has93OctaneEthanolFree?: boolean;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
// Get station details from cache
|
// Get station details from cache
|
||||||
const station = await this.repository.getCachedStation(placeId);
|
const station = await this.repository.getCachedStation(placeId);
|
||||||
@@ -62,6 +103,25 @@ export class StationsService {
|
|||||||
station
|
station
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSavedStation(
|
||||||
|
placeId: string,
|
||||||
|
userId: string,
|
||||||
|
data: UpdateSavedStationBody
|
||||||
|
) {
|
||||||
|
const updated = await this.repository.updateSavedStation(userId, placeId, data);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Saved station not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const station = await this.repository.getCachedStation(placeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
station
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getUserSavedStations(userId: string) {
|
async getUserSavedStations(userId: string) {
|
||||||
const savedStations = await this.repository.getUserSavedStations(userId);
|
const savedStations = await this.repository.getUserSavedStations(userId);
|
||||||
@@ -87,4 +147,14 @@ export class StationsService {
|
|||||||
throw new Error('Saved station not found');
|
throw new Error('Saved station not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private mapSavedMetadata(saved: SavedStation): StationSavedMetadata {
|
||||||
|
return {
|
||||||
|
nickname: saved.nickname,
|
||||||
|
notes: saved.notes,
|
||||||
|
isFavorite: saved.isFavorite,
|
||||||
|
has93Octane: saved.has93Octane,
|
||||||
|
has93OctaneEthanolFree: saved.has93OctaneEthanolFree
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface Station {
|
|||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
photoUrl?: string;
|
photoUrl?: string;
|
||||||
|
isSaved?: boolean;
|
||||||
|
savedMetadata?: StationSavedMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationSearchRequest {
|
export interface StationSearchRequest {
|
||||||
@@ -41,9 +43,12 @@ export interface SavedStation {
|
|||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
stationId: string;
|
stationId: string;
|
||||||
|
placeId: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
|
has93Octane: boolean;
|
||||||
|
has93OctaneEthanolFree: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -61,8 +66,26 @@ export interface SaveStationBody {
|
|||||||
nickname?: string;
|
nickname?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
has93Octane?: boolean;
|
||||||
|
has93OctaneEthanolFree?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationParams {
|
export interface StationParams {
|
||||||
placeId: string;
|
placeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateSavedStationBody {
|
||||||
|
nickname?: string;
|
||||||
|
notes?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
has93Octane?: boolean;
|
||||||
|
has93OctaneEthanolFree?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StationSavedMetadata {
|
||||||
|
nickname?: string;
|
||||||
|
notes?: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
has93Octane: boolean;
|
||||||
|
has93OctaneEthanolFree: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add 93 octane metadata to saved stations
|
||||||
|
ALTER TABLE saved_stations
|
||||||
|
ADD COLUMN IF NOT EXISTS has_93_octane BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE saved_stations
|
||||||
|
ADD COLUMN IF NOT EXISTS has_93_octane_ethanol_free BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Backfill existing rows with defaults
|
||||||
|
UPDATE saved_stations
|
||||||
|
SET has_93_octane = COALESCE(has_93_octane, false),
|
||||||
|
has_93_octane_ethanol_free = COALESCE(has_93_octane_ethanol_free, false);
|
||||||
@@ -52,9 +52,12 @@ export const mockSavedStations: SavedStation[] = [
|
|||||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
stationId: mockStations[0].placeId,
|
stationId: mockStations[0].placeId,
|
||||||
|
placeId: mockStations[0].placeId,
|
||||||
nickname: 'Work Gas Station',
|
nickname: 'Work Gas Station',
|
||||||
notes: 'Usually has good prices, rewards program available',
|
notes: 'Usually has good prices, rewards program available',
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: true,
|
||||||
createdAt: new Date('2024-01-01'),
|
createdAt: new Date('2024-01-01'),
|
||||||
updatedAt: new Date('2024-01-15')
|
updatedAt: new Date('2024-01-15')
|
||||||
},
|
},
|
||||||
@@ -62,9 +65,12 @@ export const mockSavedStations: SavedStation[] = [
|
|||||||
id: '550e8400-e29b-41d4-a716-446655440001',
|
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
stationId: mockStations[1].placeId,
|
stationId: mockStations[1].placeId,
|
||||||
|
placeId: mockStations[1].placeId,
|
||||||
nickname: 'Home Station',
|
nickname: 'Home Station',
|
||||||
notes: 'Closest to apartment',
|
notes: 'Closest to apartment',
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: false,
|
||||||
createdAt: new Date('2024-01-05'),
|
createdAt: new Date('2024-01-05'),
|
||||||
updatedAt: new Date('2024-01-10')
|
updatedAt: new Date('2024-01-10')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ describe('StationsService', () => {
|
|||||||
cacheStation: jest.fn().mockResolvedValue(undefined),
|
cacheStation: jest.fn().mockResolvedValue(undefined),
|
||||||
getCachedStation: jest.fn(),
|
getCachedStation: jest.fn(),
|
||||||
saveStation: jest.fn(),
|
saveStation: jest.fn(),
|
||||||
getUserSavedStations: jest.fn(),
|
getUserSavedStations: jest.fn().mockResolvedValue([]),
|
||||||
|
updateSavedStation: jest.fn(),
|
||||||
deleteSavedStation: jest.fn()
|
deleteSavedStation: jest.fn()
|
||||||
} as unknown as jest.Mocked<StationsRepository>;
|
} as unknown as jest.Mocked<StationsRepository>;
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ describe('StationsService', () => {
|
|||||||
expect(result.stations).toHaveLength(3);
|
expect(result.stations).toHaveLength(3);
|
||||||
expect(result.stations[0]?.name).toBe('Shell Gas Station - Downtown');
|
expect(result.stations[0]?.name).toBe('Shell Gas Station - Downtown');
|
||||||
expect(mockRepository.cacheStation).toHaveBeenCalledTimes(3);
|
expect(mockRepository.cacheStation).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockRepository.getUserSavedStations).toHaveBeenCalledWith(mockUserId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sort stations by distance', async () => {
|
it('should sort stations by distance', async () => {
|
||||||
@@ -108,6 +110,32 @@ describe('StationsService', () => {
|
|||||||
|
|
||||||
expect(mockRepository.cacheStation).toHaveBeenCalled();
|
expect(mockRepository.cacheStation).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should prioritize saved stations and include saved metadata', async () => {
|
||||||
|
const savedStation = mockSavedStations[0];
|
||||||
|
if (!savedStation) throw new Error('Mock saved station not found');
|
||||||
|
mockRepository.getUserSavedStations.mockResolvedValue([savedStation]);
|
||||||
|
|
||||||
|
const stationsWithDistance = [
|
||||||
|
{ ...mockStations[0], distance: 900 },
|
||||||
|
{ ...mockStations[1], distance: 100 }
|
||||||
|
];
|
||||||
|
|
||||||
|
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(
|
||||||
|
stationsWithDistance
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.searchNearbyStations(
|
||||||
|
{
|
||||||
|
latitude: searchCoordinates.sanFrancisco.latitude,
|
||||||
|
longitude: searchCoordinates.sanFrancisco.longitude
|
||||||
|
},
|
||||||
|
mockUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.stations[0]?.placeId).toBe(savedStation.stationId);
|
||||||
|
expect(result.stations[0]?.savedMetadata?.has93Octane).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('saveStation', () => {
|
describe('saveStation', () => {
|
||||||
@@ -153,13 +181,17 @@ describe('StationsService', () => {
|
|||||||
await service.saveStation(station.placeId, mockUserId, {
|
await service.saveStation(station.placeId, mockUserId, {
|
||||||
nickname: 'Favorite Station',
|
nickname: 'Favorite Station',
|
||||||
notes: 'Best prices in area',
|
notes: 'Best prices in area',
|
||||||
isFavorite: true
|
isFavorite: true,
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: false
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockRepository.saveStation).toHaveBeenCalledWith(mockUserId, station.placeId, {
|
expect(mockRepository.saveStation).toHaveBeenCalledWith(mockUserId, station.placeId, {
|
||||||
nickname: 'Favorite Station',
|
nickname: 'Favorite Station',
|
||||||
notes: 'Best prices in area',
|
notes: 'Best prices in area',
|
||||||
isFavorite: true
|
isFavorite: true,
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -188,6 +220,40 @@ describe('StationsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateSavedStation', () => {
|
||||||
|
it('should update saved station metadata', async () => {
|
||||||
|
const savedStation = mockSavedStations[0];
|
||||||
|
const station = mockStations[0];
|
||||||
|
if (!savedStation || !station) throw new Error('Mock data not found');
|
||||||
|
|
||||||
|
mockRepository.updateSavedStation.mockResolvedValue(savedStation);
|
||||||
|
mockRepository.getCachedStation.mockResolvedValue(station);
|
||||||
|
|
||||||
|
const result = await service.updateSavedStation(savedStation.stationId, mockUserId, {
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRepository.updateSavedStation).toHaveBeenCalledWith(
|
||||||
|
mockUserId,
|
||||||
|
savedStation.stationId,
|
||||||
|
{
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(result.station).toEqual(station);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if saved station not found', async () => {
|
||||||
|
mockRepository.updateSavedStation.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateSavedStation('unknown', mockUserId, { nickname: 'Update' })
|
||||||
|
).rejects.toThrow('Saved station not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('removeSavedStation', () => {
|
describe('removeSavedStation', () => {
|
||||||
it('should delete a saved station', async () => {
|
it('should delete a saved station', async () => {
|
||||||
const savedStation = mockSavedStations[0];
|
const savedStation = mockSavedStations[0];
|
||||||
|
|||||||
Binary file not shown.
@@ -1,39 +0,0 @@
|
|||||||
===========================================
|
|
||||||
MotoVaultPro Database Import Instructions
|
|
||||||
===========================================
|
|
||||||
|
|
||||||
Export Details:
|
|
||||||
- Export Date: Sun Nov 2 09:40:58 CST 2025
|
|
||||||
- Format: sql
|
|
||||||
- Compressed: true
|
|
||||||
- File: /home/egullickson/motovaultpro/database-exports/motovaultpro_export_20251102_094057.sql.gz
|
|
||||||
|
|
||||||
Import Instructions:
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
1. Copy the export file to your target server:
|
|
||||||
scp /home/egullickson/motovaultpro/database-exports/motovaultpro_export_20251102_094057.sql.gz user@server:/path/to/import/
|
|
||||||
|
|
||||||
2. Import the database (compressed SQL):
|
|
||||||
# Using Docker:
|
|
||||||
gunzip -c /path/to/import/motovaultpro_export_20251102_094057.sql.gz | docker exec -i mvp-postgres psql -U postgres -d motovaultpro
|
|
||||||
|
|
||||||
# Direct PostgreSQL:
|
|
||||||
gunzip -c /path/to/import/motovaultpro_export_20251102_094057.sql.gz | psql -U postgres -d motovaultpro
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
------
|
|
||||||
- The -c flag drops existing database objects before recreating them
|
|
||||||
- Ensure the target database exists before importing
|
|
||||||
- For production imports, always test on a staging environment first
|
|
||||||
- Consider creating a backup of the target database before importing
|
|
||||||
|
|
||||||
Create target database:
|
|
||||||
-----------------------
|
|
||||||
docker exec -i mvp-postgres psql -U postgres -c "CREATE DATABASE motovaultpro;"
|
|
||||||
|
|
||||||
Or if database exists and you want to start fresh:
|
|
||||||
--------------------------------------------------
|
|
||||||
docker exec -i mvp-postgres psql -U postgres -c "DROP DATABASE IF EXISTS motovaultpro;"
|
|
||||||
docker exec -i mvp-postgres psql -U postgres -c "CREATE DATABASE motovaultpro;"
|
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"export_timestamp": "2025-11-02T15:40:58Z",
|
|
||||||
"database_name": "motovaultpro",
|
|
||||||
"export_format": "sql",
|
|
||||||
"compressed": true,
|
|
||||||
"schema_included": true,
|
|
||||||
"data_included": true,
|
|
||||||
"postgresql_version": "PostgreSQL 15.14 on x86_64-pc-linux-musl, compiled by gcc (Alpine 14.2.0) 14.2.0, 64-bit",
|
|
||||||
"file_path": "/home/egullickson/motovaultpro/database-exports/motovaultpro_export_20251102_094057.sql.gz",
|
|
||||||
"file_size": "4.0K"
|
|
||||||
}
|
|
||||||
@@ -56,6 +56,7 @@ services:
|
|||||||
SECRETS_DIR: /run/secrets
|
SECRETS_DIR: /run/secrets
|
||||||
volumes:
|
volumes:
|
||||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||||
|
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||||
networks:
|
networks:
|
||||||
- frontend
|
- frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -103,6 +104,7 @@ services:
|
|||||||
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
|
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
|
||||||
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
|
||||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||||
|
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||||
# Filesystem storage for documents
|
# Filesystem storage for documents
|
||||||
- ./data/documents:/app/data/documents
|
- ./data/documents:/app/data/documents
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ This approach:
|
|||||||
|
|
||||||
1. **Build Time**: Container is built WITHOUT secrets (no API keys in image)
|
1. **Build Time**: Container is built WITHOUT secrets (no API keys in image)
|
||||||
2. **Container Startup**:
|
2. **Container Startup**:
|
||||||
- `/app/load-config.sh` reads `/run/secrets/google-maps-api-key`
|
- `/app/load-config.sh` reads `/run/secrets/google-maps-api-key` and `/run/secrets/google-maps-map-id`
|
||||||
- Generates `/usr/share/nginx/html/config.js` with runtime values
|
- Generates `/usr/share/nginx/html/config.js` with runtime values
|
||||||
- Starts nginx
|
- Starts nginx
|
||||||
3. **App Load Time**:
|
3. **App Load Time**:
|
||||||
@@ -86,6 +86,7 @@ fi
|
|||||||
cat > "$CONFIG_FILE" <<EOF
|
cat > "$CONFIG_FILE" <<EOF
|
||||||
window.CONFIG = {
|
window.CONFIG = {
|
||||||
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
|
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
|
||||||
|
googleMapsMapId: '$GOOGLE_MAPS_MAP_ID',
|
||||||
newApiKey: '$NEW_API_KEY'
|
newApiKey: '$NEW_API_KEY'
|
||||||
};
|
};
|
||||||
EOF
|
EOF
|
||||||
@@ -96,6 +97,7 @@ EOF
|
|||||||
```typescript
|
```typescript
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
googleMapsApiKey: string;
|
googleMapsApiKey: string;
|
||||||
|
googleMapsMapId?: string;
|
||||||
newApiKey: string; // Add new field
|
newApiKey: string; // Add new field
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +110,16 @@ export function getNewApiKey(): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGoogleMapsMapId(): string {
|
||||||
|
try {
|
||||||
|
const config = getConfig();
|
||||||
|
return config.googleMapsMapId || '';
|
||||||
|
} catch {
|
||||||
|
console.warn('Google Maps Map ID not available.');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Update docker-compose.yml
|
### 3. Update docker-compose.yml
|
||||||
@@ -116,6 +128,7 @@ export function getNewApiKey(): string {
|
|||||||
mvp-frontend:
|
mvp-frontend:
|
||||||
volumes:
|
volumes:
|
||||||
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
|
||||||
|
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
|
||||||
- ./secrets/app/new-api-key.txt:/run/secrets/new-api-key:ro # Add new secret
|
- ./secrets/app/new-api-key.txt:/run/secrets/new-api-key:ro # Add new secret
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ set -e
|
|||||||
SECRETS_DIR="${SECRETS_DIR:-/run/secrets}"
|
SECRETS_DIR="${SECRETS_DIR:-/run/secrets}"
|
||||||
CONFIG_FILE="/usr/share/nginx/html/config.js"
|
CONFIG_FILE="/usr/share/nginx/html/config.js"
|
||||||
GOOGLE_MAPS_API_KEY=""
|
GOOGLE_MAPS_API_KEY=""
|
||||||
|
GOOGLE_MAPS_MAP_ID=""
|
||||||
|
|
||||||
# Try to read Google Maps API key from secret file
|
# Try to read Google Maps API key from secret file
|
||||||
if [ -f "$SECRETS_DIR/google-maps-api-key" ]; then
|
if [ -f "$SECRETS_DIR/google-maps-api-key" ]; then
|
||||||
@@ -17,10 +18,20 @@ else
|
|||||||
GOOGLE_MAPS_API_KEY=""
|
GOOGLE_MAPS_API_KEY=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Try to read Google Maps Map ID (optional)
|
||||||
|
if [ -f "$SECRETS_DIR/google-maps-map-id" ]; then
|
||||||
|
GOOGLE_MAPS_MAP_ID=$(cat "$SECRETS_DIR/google-maps-map-id")
|
||||||
|
echo "[Config] Loaded Google Maps Map ID from $SECRETS_DIR/google-maps-map-id"
|
||||||
|
else
|
||||||
|
echo "[Config] Info: Google Maps Map ID not found at $SECRETS_DIR/google-maps-map-id (advanced markers require this)"
|
||||||
|
GOOGLE_MAPS_MAP_ID=""
|
||||||
|
fi
|
||||||
|
|
||||||
# Generate config.js
|
# Generate config.js
|
||||||
cat > "$CONFIG_FILE" <<EOF
|
cat > "$CONFIG_FILE" <<EOF
|
||||||
window.CONFIG = {
|
window.CONFIG = {
|
||||||
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY'
|
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
|
||||||
|
googleMapsMapId: '$GOOGLE_MAPS_MAP_ID'
|
||||||
};
|
};
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
/** Google Maps JavaScript API key for map visualization */
|
/** Google Maps JavaScript API key for map visualization */
|
||||||
googleMapsApiKey: string;
|
googleMapsApiKey: string;
|
||||||
|
/** Google Maps Map ID for vector basemap and advanced markers */
|
||||||
|
googleMapsMapId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +62,21 @@ export function getGoogleMapsApiKey(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Google Maps Map ID (optional)
|
||||||
|
*
|
||||||
|
* @returns Google Maps Map ID or empty string
|
||||||
|
*/
|
||||||
|
export function getGoogleMapsMapId(): string {
|
||||||
|
try {
|
||||||
|
const config = getConfig();
|
||||||
|
return config.googleMapsMapId || '';
|
||||||
|
} catch {
|
||||||
|
console.warn('Google Maps Map ID not available. Advanced map features may be limited.');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if configuration is available
|
* Check if configuration is available
|
||||||
* Useful for conditional feature enablement
|
* Useful for conditional feature enablement
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import {
|
|||||||
import { useSavedStations } from '../../stations/hooks/useSavedStations';
|
import { useSavedStations } from '../../stations/hooks/useSavedStations';
|
||||||
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
|
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
|
||||||
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
|
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
|
||||||
|
import {
|
||||||
|
resolveSavedStationAddress,
|
||||||
|
resolveSavedStationCoordinates,
|
||||||
|
resolveSavedStationName,
|
||||||
|
resolveSavedStationPlaceId
|
||||||
|
} from '../../stations/utils/savedStations';
|
||||||
import { LocationData } from '../types/fuel-logs.types';
|
import { LocationData } from '../types/fuel-logs.types';
|
||||||
|
|
||||||
interface StationPickerProps {
|
interface StationPickerProps {
|
||||||
@@ -121,10 +127,18 @@ export const StationPicker: React.FC<StationPickerProps> = ({
|
|||||||
// Add saved stations first
|
// Add saved stations first
|
||||||
if (savedStations && savedStations.length > 0) {
|
if (savedStations && savedStations.length > 0) {
|
||||||
savedStations.forEach((station) => {
|
savedStations.forEach((station) => {
|
||||||
|
const placeId = resolveSavedStationPlaceId(station);
|
||||||
|
if (!placeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStation =
|
||||||
|
station.placeId === placeId ? station : { ...station, placeId };
|
||||||
|
|
||||||
opts.push({
|
opts.push({
|
||||||
type: 'saved',
|
type: 'saved',
|
||||||
station,
|
station: normalizedStation,
|
||||||
label: station.nickname || station.name,
|
label: resolveSavedStationName(normalizedStation),
|
||||||
group: 'Saved Stations'
|
group: 'Saved Stations'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -133,7 +147,11 @@ export const StationPicker: React.FC<StationPickerProps> = ({
|
|||||||
// Add nearby stations
|
// Add nearby stations
|
||||||
if (nearbyStations && nearbyStations.length > 0) {
|
if (nearbyStations && nearbyStations.length > 0) {
|
||||||
// Filter out stations already in saved list
|
// Filter out stations already in saved list
|
||||||
const savedPlaceIds = new Set(savedStations?.map((s) => s.placeId) || []);
|
const savedPlaceIds = new Set(
|
||||||
|
(savedStations || [])
|
||||||
|
.map((station) => resolveSavedStationPlaceId(station))
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
);
|
||||||
|
|
||||||
nearbyStations
|
nearbyStations
|
||||||
.filter((station) => !savedPlaceIds.has(station.placeId))
|
.filter((station) => !savedPlaceIds.has(station.placeId))
|
||||||
@@ -171,16 +189,37 @@ export const StationPicker: React.FC<StationPickerProps> = ({
|
|||||||
// Selected from options
|
// Selected from options
|
||||||
const { station } = newValue;
|
const { station } = newValue;
|
||||||
if (station) {
|
if (station) {
|
||||||
onChange({
|
const saved = isSavedStation(station);
|
||||||
stationName: station.name,
|
const placeId = saved
|
||||||
address: station.address,
|
? resolveSavedStationPlaceId(station) || station.placeId
|
||||||
googlePlaceId: station.placeId,
|
: station.placeId;
|
||||||
coordinates: {
|
const name = saved ? resolveSavedStationName(station) : station.name;
|
||||||
latitude: station.latitude,
|
const address = saved ? resolveSavedStationAddress(station) : station.address;
|
||||||
longitude: station.longitude
|
|
||||||
|
let latitude = station.latitude;
|
||||||
|
let longitude = station.longitude;
|
||||||
|
|
||||||
|
if ((latitude === undefined || longitude === undefined) && saved) {
|
||||||
|
const coords = resolveSavedStationCoordinates(station);
|
||||||
|
if (coords) {
|
||||||
|
latitude = coords.latitude;
|
||||||
|
longitude = coords.longitude;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
stationName: name,
|
||||||
|
address,
|
||||||
|
googlePlaceId: placeId,
|
||||||
|
coordinates:
|
||||||
|
latitude !== undefined && longitude !== undefined
|
||||||
|
? {
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
});
|
});
|
||||||
setInputValue(station.name);
|
setInputValue(name);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange]
|
||||||
|
|||||||
@@ -595,17 +595,18 @@ The Gas Stations feature uses MotoVaultPro's K8s-aligned runtime configuration p
|
|||||||
### Accessing Configuration
|
### Accessing Configuration
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { getGoogleMapsApiKey } from '@/core/config/config.types';
|
import { getGoogleMapsApiKey, getGoogleMapsMapId } from '@/core/config/config.types';
|
||||||
|
|
||||||
export function MyComponent() {
|
export function MyComponent() {
|
||||||
const apiKey = getGoogleMapsApiKey();
|
const apiKey = getGoogleMapsApiKey();
|
||||||
|
const mapId = getGoogleMapsMapId();
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey || !mapId) {
|
||||||
return <div>Google Maps API key not configured</div>;
|
return <div>Google Maps configuration not complete</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use API key
|
// Use API key + map id
|
||||||
return <MapComponent apiKey={apiKey} />;
|
return <StationMap apiKey={apiKey} mapId={mapId} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -617,9 +618,11 @@ For local development (Vite dev server):
|
|||||||
# Set up secrets
|
# Set up secrets
|
||||||
mkdir -p ./secrets/app
|
mkdir -p ./secrets/app
|
||||||
echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
|
echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
|
||||||
|
echo "YOUR_MAP_ID" > ./secrets/app/google-maps-map-id.txt
|
||||||
|
|
||||||
# Alternatively, set environment variable
|
# Alternatively, set environment variable
|
||||||
export VITE_GOOGLE_MAPS_API_KEY=YOUR_API_KEY
|
export VITE_GOOGLE_MAPS_API_KEY=YOUR_API_KEY
|
||||||
|
export VITE_GOOGLE_MAPS_MAP_ID=YOUR_MAP_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
See `/frontend/docs/RUNTIME-CONFIG.md` for complete documentation.
|
See `/frontend/docs/RUNTIME-CONFIG.md` for complete documentation.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Selector for marking 93 octane availability on a saved station
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent,
|
||||||
|
FormHelperText
|
||||||
|
} from '@mui/material';
|
||||||
|
import { OctanePreference } from '../types/stations.types';
|
||||||
|
|
||||||
|
interface OctanePreferenceSelectorProps {
|
||||||
|
value: OctanePreference;
|
||||||
|
onChange: (value: OctanePreference) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_ID = 'octane-preference-select';
|
||||||
|
|
||||||
|
export const OctanePreferenceSelector: React.FC<OctanePreferenceSelectorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
helperText,
|
||||||
|
label = '93 Octane'
|
||||||
|
}) => {
|
||||||
|
const handleChange = (event: SelectChangeEvent) => {
|
||||||
|
onChange(event.target.value as OctanePreference);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl size="small" fullWidth disabled={disabled}>
|
||||||
|
<InputLabel id={LABEL_ID}>{label}</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId={LABEL_ID}
|
||||||
|
value={value}
|
||||||
|
label={label}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<MenuItem value="none">Not set</MenuItem>
|
||||||
|
<MenuItem value="with_ethanol">93 w/ Ethanol</MenuItem>
|
||||||
|
<MenuItem value="ethanol_free">93 Ethanol Free</MenuItem>
|
||||||
|
</Select>
|
||||||
|
{helperText && <FormHelperText>{helperText}</FormHelperText>}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OctanePreferenceSelector;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary List of user's saved/favorited stations
|
* @ai-summary List of user's saved/favorited stations with octane metadata editing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -18,8 +18,15 @@ import {
|
|||||||
Skeleton
|
Skeleton
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { SavedStation } from '../types/stations.types';
|
import { OctanePreference, SavedStation } from '../types/stations.types';
|
||||||
import { formatDistance } from '../utils/distance';
|
import { formatDistance } from '../utils/distance';
|
||||||
|
import {
|
||||||
|
getOctanePreferenceFromFlags,
|
||||||
|
resolveSavedStationAddress,
|
||||||
|
resolveSavedStationName,
|
||||||
|
resolveSavedStationPlaceId
|
||||||
|
} from '../utils/savedStations';
|
||||||
|
import { OctanePreferenceSelector } from './OctanePreferenceSelector';
|
||||||
|
|
||||||
interface SavedStationsListProps {
|
interface SavedStationsListProps {
|
||||||
stations: SavedStation[];
|
stations: SavedStation[];
|
||||||
@@ -27,19 +34,19 @@ interface SavedStationsListProps {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onSelectStation?: (station: SavedStation) => void;
|
onSelectStation?: (station: SavedStation) => void;
|
||||||
onDeleteStation?: (placeId: string) => void;
|
onDeleteStation?: (placeId: string) => void;
|
||||||
|
onOctanePreferenceChange?: (placeId: string, preference: OctanePreference) => void;
|
||||||
|
octaneUpdatingId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vertical list of saved stations with delete option
|
|
||||||
*/
|
|
||||||
export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
||||||
stations,
|
stations,
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null,
|
error = null,
|
||||||
onSelectStation,
|
onSelectStation,
|
||||||
onDeleteStation
|
onDeleteStation,
|
||||||
|
onOctanePreferenceChange,
|
||||||
|
octaneUpdatingId
|
||||||
}) => {
|
}) => {
|
||||||
// Loading state
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||||
@@ -56,7 +63,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ padding: 2 }}>
|
<Box sx={{ padding: 2 }}>
|
||||||
@@ -65,7 +71,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if (stations.length === 0) {
|
if (stations.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ textAlign: 'center', padding: 3 }}>
|
<Box sx={{ textAlign: 'center', padding: 3 }}>
|
||||||
@@ -84,97 +89,118 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
|
|||||||
bgcolor: 'background.paper'
|
bgcolor: 'background.paper'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{stations.map((station, index) => (
|
{stations.map((station, index) => {
|
||||||
<React.Fragment key={station.placeId}>
|
const placeId = resolveSavedStationPlaceId(station);
|
||||||
<ListItem
|
const octanePreference = getOctanePreferenceFromFlags(
|
||||||
disablePadding
|
station.has93Octane ?? false,
|
||||||
sx={{
|
station.has93OctaneEthanolFree ?? false
|
||||||
'&:hover': {
|
);
|
||||||
backgroundColor: 'action.hover'
|
|
||||||
}
|
return (
|
||||||
}}
|
<React.Fragment key={placeId ?? station.id}>
|
||||||
>
|
<ListItem
|
||||||
<ListItemButton
|
disablePadding
|
||||||
onClick={() => onSelectStation?.(station)}
|
sx={{
|
||||||
sx={{ flex: 1 }}
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover'
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemButton
|
||||||
primary={
|
onClick={() => onSelectStation?.(station)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
sx={{ flex: 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 />
|
<ListItemText
|
||||||
</IconButton>
|
primary={
|
||||||
</ListItemSecondaryAction>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
</ListItem>
|
<Typography
|
||||||
{index < stations.length - 1 && <Divider />}
|
variant="subtitle2"
|
||||||
</React.Fragment>
|
component="span"
|
||||||
))}
|
sx={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{resolveSavedStationName(station)}
|
||||||
|
</Typography>
|
||||||
|
{station.isFavorite && (
|
||||||
|
<Chip
|
||||||
|
label="Favorite"
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginTop: 0.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
{resolveSavedStationAddress(station)}
|
||||||
|
</Typography>
|
||||||
|
{station.notes && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="textSecondary"
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{station.notes}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{station.distance !== undefined && (
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
{formatDistance(station.distance)} away
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{placeId && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<OctanePreferenceSelector
|
||||||
|
value={octanePreference}
|
||||||
|
onChange={(value) => onOctanePreferenceChange?.(placeId, value)}
|
||||||
|
disabled={!onOctanePreferenceChange || octaneUpdatingId === placeId}
|
||||||
|
helperText="Show on search cards"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
aria-label="delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (placeId) {
|
||||||
|
onDeleteStation?.(placeId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Delete saved station"
|
||||||
|
sx={{
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px'
|
||||||
|
}}
|
||||||
|
disabled={!placeId}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
{index < stations.length - 1 && <Divider />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import {
|
|||||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||||
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||||
import DirectionsIcon from '@mui/icons-material/Directions';
|
import DirectionsIcon from '@mui/icons-material/Directions';
|
||||||
import { Station } from '../types/stations.types';
|
import { Station, SavedStation } from '../types/stations.types';
|
||||||
import { formatDistance } from '../utils/distance';
|
import { formatDistance } from '../utils/distance';
|
||||||
|
|
||||||
interface StationCardProps {
|
interface StationCardProps {
|
||||||
station: Station;
|
station: Station;
|
||||||
isSaved: boolean;
|
isSaved: boolean;
|
||||||
|
savedStation?: SavedStation;
|
||||||
onSave?: (station: Station) => void;
|
onSave?: (station: Station) => void;
|
||||||
onDelete?: (placeId: string) => void;
|
onDelete?: (placeId: string) => void;
|
||||||
onSelect?: (station: Station) => void;
|
onSelect?: (station: Station) => void;
|
||||||
@@ -34,6 +35,7 @@ interface StationCardProps {
|
|||||||
export const StationCard: React.FC<StationCardProps> = ({
|
export const StationCard: React.FC<StationCardProps> = ({
|
||||||
station,
|
station,
|
||||||
isSaved,
|
isSaved,
|
||||||
|
savedStation,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSelect
|
onSelect
|
||||||
@@ -53,6 +55,19 @@ export const StationCard: React.FC<StationCardProps> = ({
|
|||||||
window.open(mapsUrl, '_blank');
|
window.open(mapsUrl, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const savedMetadata = savedStation
|
||||||
|
? {
|
||||||
|
has93Octane: savedStation.has93Octane,
|
||||||
|
has93OctaneEthanolFree: savedStation.has93OctaneEthanolFree
|
||||||
|
}
|
||||||
|
: station.savedMetadata;
|
||||||
|
|
||||||
|
const octaneLabel = savedMetadata?.has93Octane
|
||||||
|
? savedMetadata.has93OctaneEthanolFree
|
||||||
|
? '93 Octane · Ethanol Free'
|
||||||
|
: '93 Octane · w/ Ethanol'
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={() => onSelect?.(station)}
|
onClick={() => onSelect?.(station)}
|
||||||
@@ -127,6 +142,16 @@ export const StationCard: React.FC<StationCardProps> = ({
|
|||||||
sx={{ marginBottom: 1 }}
|
sx={{ marginBottom: 1 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 93 Octane metadata */}
|
||||||
|
{octaneLabel && (
|
||||||
|
<Chip
|
||||||
|
label={octaneLabel}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
sx={{ marginTop: 0.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createInfoWindow,
|
createInfoWindow,
|
||||||
fitBoundsToMarkers
|
fitBoundsToMarkers
|
||||||
} from '../utils/map-utils';
|
} from '../utils/map-utils';
|
||||||
|
import { getGoogleMapsMapId } from '@/core/config/config.types';
|
||||||
|
|
||||||
interface StationMapProps {
|
interface StationMapProps {
|
||||||
stations: Station[];
|
stations: Station[];
|
||||||
@@ -41,9 +42,9 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const mapContainer = useRef<HTMLDivElement>(null);
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
const map = useRef<google.maps.Map | null>(null);
|
const map = useRef<google.maps.Map | null>(null);
|
||||||
const markers = useRef<google.maps.Marker[]>([]);
|
const markers = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
|
||||||
const infoWindows = useRef<google.maps.InfoWindow[]>([]);
|
const infoWindows = useRef<google.maps.InfoWindow[]>([]);
|
||||||
const currentLocationMarker = useRef<google.maps.Marker | null>(null);
|
const currentLocationMarker = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
|
||||||
const isInitializing = useRef<boolean>(false);
|
const isInitializing = useRef<boolean>(false);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -89,13 +90,28 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
|
|
||||||
// Create map
|
// Create map
|
||||||
const defaultCenter = center || {
|
const defaultCenter = center || {
|
||||||
lat: currentLocation?.latitude || 37.7749,
|
lat: currentLocation?.latitude || 43.074734,
|
||||||
lng: currentLocation?.longitude || -122.4194
|
lng: currentLocation?.longitude || -89.384271
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (mapIdRef.current === null) {
|
||||||
|
const mapId = getGoogleMapsMapId();
|
||||||
|
if (!mapId) {
|
||||||
|
console.error(
|
||||||
|
'[StationMap] Google Maps Map ID is not configured. Add google-maps-map-id secret to enable advanced markers.'
|
||||||
|
);
|
||||||
|
setError('Google Maps Map ID is not configured. Please contact support.');
|
||||||
|
isInitializing.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mapIdRef.current = mapId;
|
||||||
|
}
|
||||||
|
|
||||||
map.current = new maps.Map(mapContainer.current, {
|
map.current = new maps.Map(mapContainer.current, {
|
||||||
zoom,
|
zoom,
|
||||||
center: defaultCenter,
|
center: defaultCenter,
|
||||||
|
mapId: mapIdRef.current || undefined,
|
||||||
mapTypeControl: true,
|
mapTypeControl: true,
|
||||||
streetViewControl: false,
|
streetViewControl: false,
|
||||||
fullscreenControl: true
|
fullscreenControl: true
|
||||||
@@ -180,7 +196,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
try {
|
try {
|
||||||
markers.current.forEach((marker) => {
|
markers.current.forEach((marker) => {
|
||||||
try {
|
try {
|
||||||
marker.setMap(null);
|
marker.map = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore individual marker cleanup errors
|
// Ignore individual marker cleanup errors
|
||||||
}
|
}
|
||||||
@@ -205,7 +221,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentLocationMarker.current) {
|
if (currentLocationMarker.current) {
|
||||||
currentLocationMarker.current.setMap(null);
|
currentLocationMarker.current.map = null;
|
||||||
currentLocationMarker.current = null;
|
currentLocationMarker.current = null;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -232,13 +248,15 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear old markers and info windows
|
// Clear old markers and info windows
|
||||||
markers.current.forEach((marker) => marker.setMap(null));
|
markers.current.forEach((marker) => {
|
||||||
|
marker.map = null;
|
||||||
|
});
|
||||||
infoWindows.current.forEach((iw) => iw.close());
|
infoWindows.current.forEach((iw) => iw.close());
|
||||||
markers.current = [];
|
markers.current = [];
|
||||||
infoWindows.current = [];
|
infoWindows.current = [];
|
||||||
|
|
||||||
getGoogleMapsApi();
|
getGoogleMapsApi();
|
||||||
let allMarkers: google.maps.Marker[] = [];
|
let allMarkers: google.maps.marker.AdvancedMarkerElement[] = [];
|
||||||
|
|
||||||
// Add station markers
|
// Add station markers
|
||||||
stations.forEach((station) => {
|
stations.forEach((station) => {
|
||||||
@@ -256,7 +274,11 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
infoWindows.current.forEach((iw) => iw.close());
|
infoWindows.current.forEach((iw) => iw.close());
|
||||||
|
|
||||||
// Open this one
|
// Open this one
|
||||||
infoWindow.open(map.current, marker);
|
infoWindow.open({
|
||||||
|
anchor: marker,
|
||||||
|
map: map.current!,
|
||||||
|
shouldFocus: false
|
||||||
|
});
|
||||||
onMarkerClick?.(station);
|
onMarkerClick?.(station);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -264,7 +286,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
// Add current location marker
|
// Add current location marker
|
||||||
if (currentLocation) {
|
if (currentLocation) {
|
||||||
if (currentLocationMarker.current) {
|
if (currentLocationMarker.current) {
|
||||||
currentLocationMarker.current.setMap(null);
|
currentLocationMarker.current.map = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentLocationMarker.current = createCurrentLocationMarker(
|
currentLocationMarker.current = createCurrentLocationMarker(
|
||||||
@@ -346,3 +368,4 @@ export const StationMap: React.FC<StationMapProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default StationMap;
|
export default StationMap;
|
||||||
|
const mapIdRef = useRef<string | null>(null);
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button
|
Button
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Station } from '../types/stations.types';
|
import { Station, SavedStation } from '../types/stations.types';
|
||||||
import StationCard from './StationCard';
|
import StationCard from './StationCard';
|
||||||
|
|
||||||
interface StationsListProps {
|
interface StationsListProps {
|
||||||
stations: Station[];
|
stations: Station[];
|
||||||
savedPlaceIds?: Set<string>;
|
savedPlaceIds?: Set<string>;
|
||||||
|
savedStationsMap?: Map<string, SavedStation>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onSaveStation?: (station: Station) => void;
|
onSaveStation?: (station: Station) => void;
|
||||||
@@ -32,6 +33,7 @@ interface StationsListProps {
|
|||||||
export const StationsList: React.FC<StationsListProps> = ({
|
export const StationsList: React.FC<StationsListProps> = ({
|
||||||
stations,
|
stations,
|
||||||
savedPlaceIds = new Set(),
|
savedPlaceIds = new Set(),
|
||||||
|
savedStationsMap,
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null,
|
error = null,
|
||||||
onSaveStation,
|
onSaveStation,
|
||||||
@@ -92,6 +94,7 @@ export const StationsList: React.FC<StationsListProps> = ({
|
|||||||
<StationCard
|
<StationCard
|
||||||
station={station}
|
station={station}
|
||||||
isSaved={savedPlaceIds.has(station.placeId)}
|
isSaved={savedPlaceIds.has(station.placeId)}
|
||||||
|
savedStation={savedStationsMap?.get(station.placeId)}
|
||||||
onSave={onSaveStation}
|
onSave={onSaveStation}
|
||||||
onDelete={onDeleteStation}
|
onDelete={onDeleteStation}
|
||||||
onSelect={onSelectStation}
|
onSelect={onSelectStation}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
|
|||||||
setCity(e.target.value);
|
setCity(e.target.value);
|
||||||
markManualAddressInput();
|
markManualAddressInput();
|
||||||
}}
|
}}
|
||||||
placeholder="San Francisco"
|
placeholder="Madison"
|
||||||
autoComplete="address-level2"
|
autoComplete="address-level2"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
export { useStationsSearch } from './useStationsSearch';
|
export { useStationsSearch } from './useStationsSearch';
|
||||||
export { useSavedStations, useInvalidateSavedStations, useUpdateSavedStationsCache } from './useSavedStations';
|
export { useSavedStations, useInvalidateSavedStations, useUpdateSavedStationsCache } from './useSavedStations';
|
||||||
export { useSaveStation } from './useSaveStation';
|
export { useSaveStation } from './useSaveStation';
|
||||||
|
export { useUpdateSavedStation } from './useUpdateSavedStation';
|
||||||
export { useDeleteStation } from './useDeleteStation';
|
export { useDeleteStation } from './useDeleteStation';
|
||||||
export { useGeolocation } from './useGeolocation';
|
export { useGeolocation } from './useGeolocation';
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
* @ai-summary Hook for deleting saved stations
|
* @ai-summary Hook for deleting saved stations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { stationsApi } from '../api/stations.api';
|
import { stationsApi } from '../api/stations.api';
|
||||||
import { SavedStation, ApiError } from '../types/stations.types';
|
import { SavedStation, ApiError } from '../types/stations.types';
|
||||||
import { useUpdateSavedStationsCache } from './useSavedStations';
|
import { useSavedStationsQueryKey, useUpdateSavedStationsCache } from './useSavedStations';
|
||||||
|
import { resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||||
|
|
||||||
interface UseDeleteStationOptions {
|
interface UseDeleteStationOptions {
|
||||||
onSuccess?: (placeId: string) => void;
|
onSuccess?: (placeId: string) => void;
|
||||||
@@ -27,6 +28,8 @@ interface UseDeleteStationOptions {
|
|||||||
*/
|
*/
|
||||||
export function useDeleteStation(options?: UseDeleteStationOptions) {
|
export function useDeleteStation(options?: UseDeleteStationOptions) {
|
||||||
const updateCache = useUpdateSavedStationsCache();
|
const updateCache = useUpdateSavedStationsCache();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const savedStationsKey = useSavedStationsQueryKey();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (placeId: string) => {
|
mutationFn: async (placeId: string) => {
|
||||||
@@ -41,12 +44,18 @@ export function useDeleteStation(options?: UseDeleteStationOptions) {
|
|||||||
updateCache((old) => {
|
updateCache((old) => {
|
||||||
previousStations = old;
|
previousStations = old;
|
||||||
if (!old) return [];
|
if (!old) return [];
|
||||||
return old.filter((s) => s.placeId !== placeId);
|
return old.filter((station) => {
|
||||||
|
const stationPlaceId = resolveSavedStationPlaceId(station);
|
||||||
|
return stationPlaceId !== placeId;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return { previousStations, placeId };
|
return { previousStations, placeId };
|
||||||
},
|
},
|
||||||
onSuccess: (placeId) => {
|
onSuccess: (placeId) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: savedStationsKey
|
||||||
|
});
|
||||||
options?.onSuccess?.(placeId);
|
options?.onSuccess?.(placeId);
|
||||||
},
|
},
|
||||||
onError: (error, _placeId, context) => {
|
onError: (error, _placeId, context) => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function useSaveStation(options?: UseSaveStationOptions) {
|
|||||||
// Create optimistic station entry
|
// Create optimistic station entry
|
||||||
const optimisticStation: SavedStation = {
|
const optimisticStation: SavedStation = {
|
||||||
id: `temp-${placeId}`,
|
id: `temp-${placeId}`,
|
||||||
|
savedStationId: `temp-${placeId}`,
|
||||||
placeId,
|
placeId,
|
||||||
name: data.nickname || 'New Station',
|
name: data.nickname || 'New Station',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -65,6 +66,8 @@ export function useSaveStation(options?: UseSaveStationOptions) {
|
|||||||
nickname: data.nickname,
|
nickname: data.nickname,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
isFavorite: data.isFavorite ?? false,
|
isFavorite: data.isFavorite ?? false,
|
||||||
|
has93Octane: data.has93Octane ?? false,
|
||||||
|
has93OctaneEthanolFree: data.has93OctaneEthanolFree ?? false,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Hook for updating saved station metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { stationsApi } from '../api/stations.api';
|
||||||
|
import { ApiError, SavedStation, SaveStationData } from '../types/stations.types';
|
||||||
|
import { useUpdateSavedStationsCache } from './useSavedStations';
|
||||||
|
import { resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||||
|
|
||||||
|
interface UpdateSavedStationVariables {
|
||||||
|
placeId: string;
|
||||||
|
data: Partial<SaveStationData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseUpdateSavedStationOptions {
|
||||||
|
onSuccess?: (station: SavedStation) => void;
|
||||||
|
onError?: (error: ApiError) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to update saved station metadata (octane flags, nickname, etc.)
|
||||||
|
*/
|
||||||
|
export function useUpdateSavedStation(options?: UseUpdateSavedStationOptions) {
|
||||||
|
const updateCache = useUpdateSavedStationsCache();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ placeId, data }: UpdateSavedStationVariables) => {
|
||||||
|
return stationsApi.updateSavedStation(placeId, data);
|
||||||
|
},
|
||||||
|
onMutate: async ({ placeId, data }) => {
|
||||||
|
let previousStations: SavedStation[] | undefined;
|
||||||
|
|
||||||
|
updateCache((old) => {
|
||||||
|
previousStations = old;
|
||||||
|
if (!old) return [];
|
||||||
|
|
||||||
|
return old.map((station) => {
|
||||||
|
const stationPlaceId = resolveSavedStationPlaceId(station);
|
||||||
|
if (stationPlaceId !== placeId) {
|
||||||
|
return station;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...station,
|
||||||
|
...data,
|
||||||
|
has93Octane:
|
||||||
|
data.has93Octane !== undefined ? data.has93Octane : station.has93Octane,
|
||||||
|
has93OctaneEthanolFree:
|
||||||
|
data.has93OctaneEthanolFree !== undefined
|
||||||
|
? data.has93OctaneEthanolFree
|
||||||
|
: station.has93OctaneEthanolFree
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousStations };
|
||||||
|
},
|
||||||
|
onSuccess: (station) => {
|
||||||
|
options?.onSuccess?.(station);
|
||||||
|
},
|
||||||
|
onError: (error, _variables, context) => {
|
||||||
|
if (context?.previousStations) {
|
||||||
|
updateCache(() => context.previousStations || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
options?.onError?.(error as ApiError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,14 +30,17 @@ import {
|
|||||||
useSavedStations,
|
useSavedStations,
|
||||||
useSaveStation,
|
useSaveStation,
|
||||||
useDeleteStation,
|
useDeleteStation,
|
||||||
|
useUpdateSavedStation,
|
||||||
useGeolocation
|
useGeolocation
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Station,
|
Station,
|
||||||
SavedStation,
|
SavedStation,
|
||||||
StationSearchRequest
|
StationSearchRequest,
|
||||||
|
OctanePreference
|
||||||
} from '../types/stations.types';
|
} from '../types/stations.types';
|
||||||
|
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||||
|
|
||||||
// Tab indices
|
// Tab indices
|
||||||
const TAB_SEARCH = 0;
|
const TAB_SEARCH = 0;
|
||||||
@@ -56,6 +59,7 @@ export const StationsMobileScreen: React.FC = () => {
|
|||||||
// Bottom sheet state
|
// Bottom sheet state
|
||||||
const [selectedStation, setSelectedStation] = useState<Station | SavedStation | null>(null);
|
const [selectedStation, setSelectedStation] = useState<Station | SavedStation | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
const { coordinates } = useGeolocation();
|
const { coordinates } = useGeolocation();
|
||||||
@@ -74,10 +78,28 @@ export const StationsMobileScreen: React.FC = () => {
|
|||||||
|
|
||||||
const { mutateAsync: saveStation } = useSaveStation();
|
const { mutateAsync: saveStation } = useSaveStation();
|
||||||
const { mutateAsync: deleteStation } = useDeleteStation();
|
const { mutateAsync: deleteStation } = useDeleteStation();
|
||||||
|
const { mutateAsync: updateSavedStation } = useUpdateSavedStation();
|
||||||
|
|
||||||
// Compute set of saved place IDs for quick lookup
|
// Compute set of saved place IDs for quick lookup
|
||||||
const savedPlaceIds = useMemo(() => {
|
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
|
||||||
return new Set(savedStations?.map(s => s.placeId) || []);
|
const map = new Map<string, SavedStation>();
|
||||||
|
|
||||||
|
(savedStations || []).forEach((station) => {
|
||||||
|
const placeId = resolveSavedStationPlaceId(station);
|
||||||
|
if (!placeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStation =
|
||||||
|
station.placeId === placeId ? station : { ...station, placeId };
|
||||||
|
|
||||||
|
map.set(placeId, normalizedStation);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
savedStationsMap: map,
|
||||||
|
savedPlaceIds: new Set(map.keys())
|
||||||
|
};
|
||||||
}, [savedStations]);
|
}, [savedStations]);
|
||||||
|
|
||||||
// Handle search submission
|
// Handle search submission
|
||||||
@@ -121,6 +143,21 @@ export const StationsMobileScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [deleteStation, selectedStation]);
|
}, [deleteStation, selectedStation]);
|
||||||
|
|
||||||
|
const handleOctanePreferenceChange = useCallback(
|
||||||
|
async (placeId: string, preference: OctanePreference) => {
|
||||||
|
try {
|
||||||
|
setOctaneUpdatingId(placeId);
|
||||||
|
const data = octanePreferenceToFlags(preference);
|
||||||
|
await updateSavedStation({ placeId, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update octane preference:', error);
|
||||||
|
} finally {
|
||||||
|
setOctaneUpdatingId((current) => (current === placeId ? null : current));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateSavedStation]
|
||||||
|
);
|
||||||
|
|
||||||
// Close bottom sheet
|
// Close bottom sheet
|
||||||
const handleCloseDrawer = useCallback(() => {
|
const handleCloseDrawer = useCallback(() => {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
@@ -214,6 +251,7 @@ export const StationsMobileScreen: React.FC = () => {
|
|||||||
<StationsList
|
<StationsList
|
||||||
stations={searchResults}
|
stations={searchResults}
|
||||||
savedPlaceIds={savedPlaceIds}
|
savedPlaceIds={savedPlaceIds}
|
||||||
|
savedStationsMap={savedStationsMap}
|
||||||
loading={isSearching}
|
loading={isSearching}
|
||||||
error={searchError ? 'Failed to search stations' : null}
|
error={searchError ? 'Failed to search stations' : null}
|
||||||
onSaveStation={handleSaveStation}
|
onSaveStation={handleSaveStation}
|
||||||
@@ -235,6 +273,8 @@ export const StationsMobileScreen: React.FC = () => {
|
|||||||
error={savedError ? 'Failed to load saved stations' : null}
|
error={savedError ? 'Failed to load saved stations' : null}
|
||||||
onSelectStation={handleSelectStation}
|
onSelectStation={handleSelectStation}
|
||||||
onDeleteStation={handleDeleteStation}
|
onDeleteStation={handleDeleteStation}
|
||||||
|
onOctanePreferenceChange={handleOctanePreferenceChange}
|
||||||
|
octaneUpdatingId={octaneUpdatingId}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Station, StationSearchRequest } from '../types/stations.types';
|
import { OctanePreference, SavedStation, Station, StationSearchRequest } from '../types/stations.types';
|
||||||
import {
|
import {
|
||||||
useStationsSearch,
|
useStationsSearch,
|
||||||
useSavedStations,
|
useSavedStations,
|
||||||
useSaveStation,
|
useSaveStation,
|
||||||
useDeleteStation
|
useDeleteStation,
|
||||||
|
useUpdateSavedStation
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import {
|
import {
|
||||||
StationMap,
|
StationMap,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
StationsSearchForm,
|
StationsSearchForm,
|
||||||
GoogleMapsErrorBoundary
|
GoogleMapsErrorBoundary
|
||||||
} from '../components';
|
} from '../components';
|
||||||
|
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -119,12 +121,30 @@ export const StationsPage: React.FC = () => {
|
|||||||
|
|
||||||
const { mutate: saveStation } = useSaveStation();
|
const { mutate: saveStation } = useSaveStation();
|
||||||
const { mutate: deleteStation } = useDeleteStation();
|
const { mutate: deleteStation } = useDeleteStation();
|
||||||
|
const { mutate: updateSavedStation } = useUpdateSavedStation();
|
||||||
|
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Create set of saved place IDs for quick lookup
|
// Create set of saved place IDs for quick lookup
|
||||||
const savedPlaceIds = useMemo(
|
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
|
||||||
() => new Set(savedStations.map((s) => s.placeId)),
|
const map = new Map<string, SavedStation>();
|
||||||
[savedStations]
|
|
||||||
);
|
savedStations.forEach((station) => {
|
||||||
|
const placeId = resolveSavedStationPlaceId(station);
|
||||||
|
if (!placeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStation =
|
||||||
|
station.placeId === placeId ? station : { ...station, placeId };
|
||||||
|
|
||||||
|
map.set(placeId, normalizedStation);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
savedStationsMap: map,
|
||||||
|
savedPlaceIds: new Set(map.keys())
|
||||||
|
};
|
||||||
|
}, [savedStations]);
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
const handleSearch = (request: StationSearchRequest) => {
|
const handleSearch = (request: StationSearchRequest) => {
|
||||||
@@ -163,6 +183,23 @@ export const StationsPage: React.FC = () => {
|
|||||||
deleteStation(placeId);
|
deleteStation(placeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOctanePreferenceChange = useCallback(
|
||||||
|
(placeId: string, preference: OctanePreference) => {
|
||||||
|
const flags = octanePreferenceToFlags(preference);
|
||||||
|
setOctaneUpdatingId(placeId);
|
||||||
|
|
||||||
|
updateSavedStation(
|
||||||
|
{ placeId, data: flags },
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
setOctaneUpdatingId((current) => (current === placeId ? null : current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateSavedStation]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle station selection - wrapped in useCallback to prevent infinite renders
|
// Handle station selection - wrapped in useCallback to prevent infinite renders
|
||||||
const handleSelectStation = useCallback((station: Station) => {
|
const handleSelectStation = useCallback((station: Station) => {
|
||||||
setMapCenter({
|
setMapCenter({
|
||||||
@@ -238,6 +275,7 @@ export const StationsPage: React.FC = () => {
|
|||||||
<StationsList
|
<StationsList
|
||||||
stations={searchResults}
|
stations={searchResults}
|
||||||
savedPlaceIds={savedPlaceIds}
|
savedPlaceIds={savedPlaceIds}
|
||||||
|
savedStationsMap={savedStationsMap}
|
||||||
loading={isSearching}
|
loading={isSearching}
|
||||||
error={searchError ? (searchError as any).message : null}
|
error={searchError ? (searchError as any).message : null}
|
||||||
onSaveStation={handleSave}
|
onSaveStation={handleSave}
|
||||||
@@ -252,90 +290,118 @@ export const StationsPage: React.FC = () => {
|
|||||||
error={savedError ? (savedError as any).message : null}
|
error={savedError ? (savedError as any).message : null}
|
||||||
onSelectStation={handleSelectStation}
|
onSelectStation={handleSelectStation}
|
||||||
onDeleteStation={handleDelete}
|
onDeleteStation={handleDelete}
|
||||||
|
onOctanePreferenceChange={handleOctanePreferenceChange}
|
||||||
|
octaneUpdatingId={octaneUpdatingId}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop layout: side-by-side
|
// Desktop layout: top-row map + search, full-width results
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2} sx={{ padding: 2, height: 'calc(100vh - 80px)' }}>
|
<Box sx={{ padding: 2 }}>
|
||||||
{/* Left: Map (60%) */}
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
{/* Map */}
|
||||||
<Paper sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
<Grid item xs={12} md={6}>
|
||||||
{isMapReady ? (
|
<Paper
|
||||||
<GoogleMapsErrorBoundary>
|
sx={{
|
||||||
<StationMap
|
height: { xs: 300, md: 520 },
|
||||||
key="desktop-station-map"
|
display: 'flex',
|
||||||
stations={searchResults}
|
overflow: 'hidden'
|
||||||
savedPlaceIds={savedPlaceIds}
|
}}
|
||||||
currentLocation={currentLocation}
|
|
||||||
center={mapCenter || undefined}
|
|
||||||
height="100%"
|
|
||||||
readyToRender={true}
|
|
||||||
/>
|
|
||||||
</GoogleMapsErrorBoundary>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Right: Search + Tabs (40%) */}
|
|
||||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
{/* Search Form */}
|
|
||||||
<Paper>
|
|
||||||
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Error Alert */}
|
|
||||||
{searchError && (
|
|
||||||
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
||||||
<Tabs
|
|
||||||
value={tabValue}
|
|
||||||
onChange={(_, newValue) => setTabValue(newValue)}
|
|
||||||
indicatorColor="primary"
|
|
||||||
textColor="primary"
|
|
||||||
aria-label="stations tabs"
|
|
||||||
>
|
>
|
||||||
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
|
{isMapReady ? (
|
||||||
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
|
<GoogleMapsErrorBoundary>
|
||||||
</Tabs>
|
<StationMap
|
||||||
|
key="desktop-station-map"
|
||||||
|
stations={searchResults}
|
||||||
|
savedPlaceIds={savedPlaceIds}
|
||||||
|
currentLocation={currentLocation}
|
||||||
|
center={mapCenter || undefined}
|
||||||
|
height="100%"
|
||||||
|
readyToRender={true}
|
||||||
|
/>
|
||||||
|
</GoogleMapsErrorBoundary>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Tab Content with overflow */}
|
{/* Search form */}
|
||||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
<Grid item xs={12} md={6}>
|
||||||
<TabPanel value={tabValue} index={0}>
|
<Paper
|
||||||
<StationsList
|
sx={{
|
||||||
stations={searchResults}
|
height: { xs: 'auto', md: 520 },
|
||||||
savedPlaceIds={savedPlaceIds}
|
display: 'flex',
|
||||||
loading={isSearching}
|
flexDirection: 'column',
|
||||||
error={searchError ? (searchError as any).message : null}
|
overflow: 'hidden'
|
||||||
onSaveStation={handleSave}
|
}}
|
||||||
onDeleteStation={handleDelete}
|
>
|
||||||
onSelectStation={handleSelectStation}
|
<Box sx={{ padding: 2, flex: 1 }}>
|
||||||
/>
|
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
|
||||||
</TabPanel>
|
</Box>
|
||||||
|
</Paper>
|
||||||
<TabPanel value={tabValue} index={1}>
|
</Grid>
|
||||||
<SavedStationsList
|
|
||||||
stations={savedStations}
|
|
||||||
loading={isSavedLoading}
|
|
||||||
error={savedError ? (savedError as any).message : null}
|
|
||||||
onSelectStation={handleSelectStation}
|
|
||||||
onDeleteStation={handleDelete}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{searchError && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full-width Results */}
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={(_, newValue) => setTabValue(newValue)}
|
||||||
|
indicatorColor="primary"
|
||||||
|
textColor="primary"
|
||||||
|
aria-label="stations tabs"
|
||||||
|
>
|
||||||
|
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
|
||||||
|
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflow: 'auto', padding: 2 }}>
|
||||||
|
<TabPanel value={tabValue} index={0}>
|
||||||
|
<StationsList
|
||||||
|
stations={searchResults}
|
||||||
|
savedPlaceIds={savedPlaceIds}
|
||||||
|
savedStationsMap={savedStationsMap}
|
||||||
|
loading={isSearching}
|
||||||
|
error={searchError ? (searchError as any).message : null}
|
||||||
|
onSaveStation={handleSave}
|
||||||
|
onDeleteStation={handleDelete}
|
||||||
|
onSelectStation={handleSelectStation}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
<SavedStationsList
|
||||||
|
stations={savedStations}
|
||||||
|
loading={isSavedLoading}
|
||||||
|
error={savedError ? (savedError as any).message : null}
|
||||||
|
onSelectStation={handleSelectStation}
|
||||||
|
onDeleteStation={handleDelete}
|
||||||
|
onOctanePreferenceChange={handleOctanePreferenceChange}
|
||||||
|
octaneUpdatingId={octaneUpdatingId}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ declare global {
|
|||||||
*/
|
*/
|
||||||
class InfoWindow {
|
class InfoWindow {
|
||||||
constructor(options?: google.maps.InfoWindowOptions);
|
constructor(options?: google.maps.InfoWindowOptions);
|
||||||
open(map?: google.maps.Map | null, anchor?: google.maps.Marker): void;
|
open(target?: google.maps.Map | { anchor?: any; map?: google.maps.Map | null; shouldFocus?: boolean } | null, anchor?: google.maps.Marker): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
setContent(content: string | HTMLElement): void;
|
setContent(content: string | HTMLElement): void;
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,27 @@ declare global {
|
|||||||
BACKWARD_OPEN_ARROW = 'BACKWARD_OPEN_ARROW'
|
BACKWARD_OPEN_ARROW = 'BACKWARD_OPEN_ARROW'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace marker {
|
||||||
|
interface AdvancedMarkerElementOptions {
|
||||||
|
position?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||||
|
map?: google.maps.Map | null;
|
||||||
|
title?: string;
|
||||||
|
content?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdvancedMarkerElement {
|
||||||
|
constructor(options?: AdvancedMarkerElementOptions);
|
||||||
|
map: google.maps.Map | null;
|
||||||
|
position?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||||
|
content?: HTMLElement;
|
||||||
|
title?: string;
|
||||||
|
addListener(
|
||||||
|
eventName: string,
|
||||||
|
callback: (...args: any[]) => void
|
||||||
|
): google.maps.MapsEventListener;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Maps Event Listener
|
* Google Maps Event Listener
|
||||||
*/
|
*/
|
||||||
@@ -91,6 +112,7 @@ declare global {
|
|||||||
zoom?: number;
|
zoom?: number;
|
||||||
center?: google.maps.LatLng | google.maps.LatLngLiteral;
|
center?: google.maps.LatLng | google.maps.LatLngLiteral;
|
||||||
mapTypeId?: string;
|
mapTypeId?: string;
|
||||||
|
mapId?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export interface SearchLocation {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StationSavedMetadata {
|
||||||
|
nickname?: string;
|
||||||
|
notes?: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
has93Octane: boolean;
|
||||||
|
has93OctaneEthanolFree: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single gas station from search results
|
* Single gas station from search results
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +58,10 @@ export interface Station {
|
|||||||
distance?: number;
|
distance?: number;
|
||||||
/** URL to station photo if available */
|
/** URL to station photo if available */
|
||||||
photoUrl?: string;
|
photoUrl?: string;
|
||||||
|
/** Whether the station is saved for the user */
|
||||||
|
isSaved?: boolean;
|
||||||
|
/** Saved-station metadata if applicable */
|
||||||
|
savedMetadata?: StationSavedMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,18 +70,28 @@ export interface Station {
|
|||||||
export interface SavedStation extends Station {
|
export interface SavedStation extends Station {
|
||||||
/** Database record ID */
|
/** Database record ID */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Optional saved-station identifier (alias for id when needed) */
|
||||||
|
savedStationId?: string;
|
||||||
/** User ID who saved the station */
|
/** User ID who saved the station */
|
||||||
userId: string;
|
userId: string;
|
||||||
|
/** Stored station id (Google place id) */
|
||||||
|
stationId?: string;
|
||||||
/** Custom nickname given by user */
|
/** Custom nickname given by user */
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
/** User notes about the station */
|
/** User notes about the station */
|
||||||
notes?: string;
|
notes?: string;
|
||||||
/** Whether station is marked as favorite */
|
/** Whether station is marked as favorite */
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
|
/** Whether the station is confirmed to have 93 octane */
|
||||||
|
has93Octane: boolean;
|
||||||
|
/** Whether the 93 octane is ethanol free */
|
||||||
|
has93OctaneEthanolFree: boolean;
|
||||||
/** Created timestamp */
|
/** Created timestamp */
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
/** Last updated timestamp */
|
/** Last updated timestamp */
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
/** Raw station object returned by backend, if any */
|
||||||
|
station?: Station | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,6 +118,10 @@ export interface SaveStationData {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
/** Whether to mark as favorite */
|
/** Whether to mark as favorite */
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
/** Whether 93 octane is available */
|
||||||
|
has93Octane?: boolean;
|
||||||
|
/** Whether the 93 octane option is ethanol free */
|
||||||
|
has93OctaneEthanolFree?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,3 +163,6 @@ export interface ApiError {
|
|||||||
code?: string;
|
code?: string;
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-facing preference for 93 octane availability */
|
||||||
|
export type OctanePreference = 'none' | 'with_ethanol' | 'ethanol_free';
|
||||||
|
|||||||
@@ -1,58 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Google Maps utility functions
|
* @ai-summary Google Maps utility helpers using AdvancedMarkerElement
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getGoogleMapsApi } from './maps-loader';
|
import { getGoogleMapsApi } from './maps-loader';
|
||||||
import { Station, MapMarker } from '../types/stations.types';
|
import { Station, MapMarker } from '../types/stations.types';
|
||||||
import { formatDistance } from './distance';
|
import { formatDistance } from './distance';
|
||||||
|
|
||||||
/**
|
type AdvancedMarker = google.maps.marker.AdvancedMarkerElement;
|
||||||
* Create a marker for a station
|
|
||||||
*
|
function createMarkerElement(color: string, label?: string): HTMLElement {
|
||||||
* @param station Station data
|
const marker = document.createElement('div');
|
||||||
* @param map Google Map instance
|
marker.style.width = '24px';
|
||||||
* @param isSaved Whether station is saved
|
marker.style.height = '24px';
|
||||||
* @returns Google Maps Marker
|
marker.style.borderRadius = '50%';
|
||||||
*/
|
marker.style.backgroundColor = color;
|
||||||
|
marker.style.border = '2px solid #ffffff';
|
||||||
|
marker.style.boxShadow = '0 1px 4px rgba(0,0,0,0.4)';
|
||||||
|
marker.style.display = 'flex';
|
||||||
|
marker.style.alignItems = 'center';
|
||||||
|
marker.style.justifyContent = 'center';
|
||||||
|
marker.style.color = '#000';
|
||||||
|
marker.style.fontSize = '12px';
|
||||||
|
marker.style.fontWeight = 'bold';
|
||||||
|
marker.style.lineHeight = '1';
|
||||||
|
marker.style.transform = 'translate(-50%, -50%)';
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
marker.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
export function createStationMarker(
|
export function createStationMarker(
|
||||||
station: Station,
|
station: Station,
|
||||||
map: google.maps.Map,
|
map: google.maps.Map,
|
||||||
isSaved: boolean
|
isSaved: boolean
|
||||||
): google.maps.Marker {
|
): AdvancedMarker {
|
||||||
const maps = getGoogleMapsApi();
|
const maps = getGoogleMapsApi();
|
||||||
const markerColor = isSaved ? '#FFD700' : '#4285F4'; // Gold for saved, blue for normal
|
const markerColor = isSaved ? '#FFD700' : '#4285F4';
|
||||||
|
const content = createMarkerElement(markerColor, isSaved ? '★' : undefined);
|
||||||
|
|
||||||
const marker = new maps.Marker({
|
const marker = new maps.marker.AdvancedMarkerElement({
|
||||||
position: {
|
position: {
|
||||||
lat: station.latitude,
|
lat: station.latitude,
|
||||||
lng: station.longitude
|
lng: station.longitude
|
||||||
},
|
},
|
||||||
map,
|
map,
|
||||||
title: station.name,
|
title: station.name,
|
||||||
icon: {
|
content
|
||||||
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).stationData = station;
|
||||||
(marker as any).isSaved = isSaved;
|
(marker as any).isSaved = isSaved;
|
||||||
|
|
||||||
return marker;
|
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(
|
export function createInfoWindow(
|
||||||
station: Station,
|
station: Station,
|
||||||
isSaved: boolean
|
isSaved: boolean
|
||||||
@@ -73,11 +77,15 @@ export function createInfoWindow(
|
|||||||
}
|
}
|
||||||
${
|
${
|
||||||
station.rating
|
station.rating
|
||||||
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(1)}</p>`
|
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(
|
||||||
|
1
|
||||||
|
)}</p>`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
<div style="margin-top: 8px;">
|
<div style="margin-top: 8px;">
|
||||||
<a href="https://www.google.com/maps/search/${encodeURIComponent(station.address)}" target="_blank" style="color: #4285F4; text-decoration: none; font-size: 12px; margin-right: 8px;">
|
<a href="https://www.google.com/maps/search/${encodeURIComponent(
|
||||||
|
station.address
|
||||||
|
)}" target="_blank" style="color: #4285F4; text-decoration: none; font-size: 12px; margin-right: 8px;">
|
||||||
Directions
|
Directions
|
||||||
</a>
|
</a>
|
||||||
${isSaved ? '<span style="color: #FFD700; font-size: 12px;">★ Saved</span>' : ''}
|
${isSaved ? '<span style="color: #FFD700; font-size: 12px;">★ Saved</span>' : ''}
|
||||||
@@ -85,20 +93,12 @@ export function createInfoWindow(
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return new maps.InfoWindow({
|
return new maps.InfoWindow({ content });
|
||||||
content
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fit map bounds to show all markers
|
|
||||||
*
|
|
||||||
* @param map Google Map instance
|
|
||||||
* @param markers Array of markers
|
|
||||||
*/
|
|
||||||
export function fitBoundsToMarkers(
|
export function fitBoundsToMarkers(
|
||||||
map: google.maps.Map,
|
map: google.maps.Map,
|
||||||
markers: google.maps.Marker[]
|
markers: AdvancedMarker[]
|
||||||
): void {
|
): void {
|
||||||
if (markers.length === 0) return;
|
if (markers.length === 0) return;
|
||||||
|
|
||||||
@@ -106,55 +106,37 @@ export function fitBoundsToMarkers(
|
|||||||
const bounds = new maps.LatLngBounds();
|
const bounds = new maps.LatLngBounds();
|
||||||
|
|
||||||
markers.forEach((marker) => {
|
markers.forEach((marker) => {
|
||||||
const position = marker.getPosition();
|
const positionLiteral = marker.position;
|
||||||
if (position) {
|
if (!positionLiteral) {
|
||||||
bounds.extend(position);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latLng =
|
||||||
|
positionLiteral instanceof maps.LatLng
|
||||||
|
? positionLiteral
|
||||||
|
: new maps.LatLng(positionLiteral.lat, positionLiteral.lng);
|
||||||
|
bounds.extend(latLng);
|
||||||
});
|
});
|
||||||
|
|
||||||
map.fitBounds(bounds);
|
map.fitBounds(bounds, { top: 50, right: 50, bottom: 50, left: 50 });
|
||||||
|
|
||||||
// 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(
|
export function createCurrentLocationMarker(
|
||||||
latitude: number,
|
latitude: number,
|
||||||
longitude: number,
|
longitude: number,
|
||||||
map: google.maps.Map
|
map: google.maps.Map
|
||||||
): google.maps.Marker {
|
): AdvancedMarker {
|
||||||
const maps = getGoogleMapsApi();
|
const maps = getGoogleMapsApi();
|
||||||
|
const content = createMarkerElement('#FF0000');
|
||||||
|
|
||||||
return new maps.Marker({
|
return new maps.marker.AdvancedMarkerElement({
|
||||||
position: {
|
position: { lat: latitude, lng: longitude },
|
||||||
lat: latitude,
|
|
||||||
lng: longitude
|
|
||||||
},
|
|
||||||
map,
|
map,
|
||||||
title: 'Your Location',
|
title: 'Your Location',
|
||||||
icon: {
|
content
|
||||||
path: maps.SymbolPath.CIRCLE,
|
|
||||||
scale: 10,
|
|
||||||
fillColor: '#FF0000',
|
|
||||||
fillOpacity: 0.7,
|
|
||||||
strokeColor: '#fff',
|
|
||||||
strokeWeight: 2
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Station to MapMarker
|
|
||||||
*/
|
|
||||||
export function stationToMapMarker(
|
export function stationToMapMarker(
|
||||||
station: Station,
|
station: Station,
|
||||||
isSaved: boolean
|
isSaved: boolean
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function loadGoogleMaps(): Promise<void> {
|
|||||||
// The callback parameter tells Google Maps to call our function when ready
|
// The callback parameter tells Google Maps to call our function when ready
|
||||||
// Using async + callback ensures Google Maps initializes asynchronously
|
// Using async + callback ensures Google Maps initializes asynchronously
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places&loading=async`;
|
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places,marker&loading=async`;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.defer = true; // Load asynchronously without blocking parsing (per Maps best practices)
|
script.defer = true; // Load asynchronously without blocking parsing (per Maps best practices)
|
||||||
|
|
||||||
|
|||||||
69
frontend/src/features/stations/utils/savedStations.ts
Normal file
69
frontend/src/features/stations/utils/savedStations.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Helper utilities for working with saved stations data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OctanePreference, SavedStation, SaveStationData } from '../types/stations.types';
|
||||||
|
|
||||||
|
export function resolveSavedStationPlaceId(station: SavedStation): string | undefined {
|
||||||
|
return station.placeId || station.station?.placeId || station.stationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSavedStationName(station: SavedStation): string {
|
||||||
|
return (
|
||||||
|
station.nickname ||
|
||||||
|
station.name ||
|
||||||
|
station.station?.name ||
|
||||||
|
'Saved Station'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSavedStationAddress(station: SavedStation): string {
|
||||||
|
return station.address || station.station?.address || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSavedStationCoordinates(
|
||||||
|
station: SavedStation
|
||||||
|
): { latitude: number; longitude: number } | undefined {
|
||||||
|
const lat = station.latitude ?? station.station?.latitude;
|
||||||
|
const lng = station.longitude ?? station.station?.longitude;
|
||||||
|
|
||||||
|
if (lat === undefined || lng === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { latitude: lat, longitude: lng };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOctanePreferenceFromFlags(
|
||||||
|
has93Octane: boolean,
|
||||||
|
has93OctaneEthanolFree: boolean
|
||||||
|
): OctanePreference {
|
||||||
|
if (!has93Octane) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
return has93OctaneEthanolFree ? 'ethanol_free' : 'with_ethanol';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function octanePreferenceToFlags(
|
||||||
|
preference: OctanePreference
|
||||||
|
): Pick<SaveStationData, 'has93Octane' | 'has93OctaneEthanolFree'> {
|
||||||
|
if (preference === 'none') {
|
||||||
|
return {
|
||||||
|
has93Octane: false,
|
||||||
|
has93OctaneEthanolFree: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preference === 'ethanol_free') {
|
||||||
|
return {
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
has93Octane: true,
|
||||||
|
has93OctaneEthanolFree: false
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -67,6 +67,15 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
initialData,
|
initialData,
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
|
const formatVehicleLabel = (value?: string): string => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
const [years, setYears] = useState<number[]>([]);
|
const [years, setYears] = useState<number[]>([]);
|
||||||
const [makes, setMakes] = useState<DropdownOption[]>([]);
|
const [makes, setMakes] = useState<DropdownOption[]>([]);
|
||||||
const [models, setModels] = useState<DropdownOption[]>([]);
|
const [models, setModels] = useState<DropdownOption[]>([]);
|
||||||
@@ -338,7 +347,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
<option value="">Select Make</option>
|
<option value="">Select Make</option>
|
||||||
{makes.map((make) => (
|
{makes.map((make) => (
|
||||||
<option key={make.id} value={make.name}>
|
<option key={make.id} value={make.name}>
|
||||||
{make.name}
|
{formatVehicleLabel(make.name)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -357,7 +366,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
<option value="">Select Model</option>
|
<option value="">Select Model</option>
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<option key={model.id} value={model.name}>
|
<option key={model.id} value={model.name}>
|
||||||
{model.name}
|
{formatVehicleLabel(model.name)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Test script for bulk catalog delete endpoint
|
|
||||||
# Note: This is a test script to verify the endpoint structure
|
|
||||||
|
|
||||||
BASE_URL="http://localhost/api"
|
|
||||||
|
|
||||||
echo "Testing bulk delete catalog endpoint"
|
|
||||||
echo "===================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Test 1: Invalid entity type
|
|
||||||
echo "Test 1: Invalid entity type (should return 400)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/invalid/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": [1, 2, 3]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 2: Empty IDs array
|
|
||||||
echo "Test 2: Empty IDs array (should return 400)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": []}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 3: Invalid IDs (negative numbers)
|
|
||||||
echo "Test 3: Invalid IDs - negative numbers (should return 400)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": [1, -2, 3]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 4: Invalid IDs (strings instead of numbers)
|
|
||||||
echo "Test 4: Invalid IDs - strings (should return 400)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": ["abc", "def"]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 5: Valid request format (will fail without auth, but shows structure)
|
|
||||||
echo "Test 5: Valid request format - makes (needs auth)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": [999, 998]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 6: Valid request format - models (needs auth)
|
|
||||||
echo "Test 6: Valid request format - models (needs auth)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/models/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": [999, 998]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 7: Valid request format - years (needs auth)
|
|
||||||
echo "Test 7: Valid request format - years (needs auth)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/years/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": [999, 998]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 8: Valid request format - trims (needs auth)
|
|
||||||
echo "Test 8: Valid request format - trims (needs auth)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/trims/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": [999, 998]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
# Test 9: Valid request format - engines (needs auth)
|
|
||||||
echo "Test 9: Valid request format - engines (needs auth)"
|
|
||||||
curl -X DELETE "${BASE_URL}/admin/catalog/engines/bulk-delete" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ids": [999, 998]}' \
|
|
||||||
-w "\nStatus: %{http_code}\n\n"
|
|
||||||
|
|
||||||
echo "===================================="
|
|
||||||
echo "Test script complete"
|
|
||||||
echo ""
|
|
||||||
echo "Expected results:"
|
|
||||||
echo "- Tests 1-4: Should return 400 (Bad Request)"
|
|
||||||
echo "- Tests 5-9: Should return 401 (Unauthorized) without auth token"
|
|
||||||
echo ""
|
|
||||||
echo "Note: Full testing requires admin authentication token"
|
|
||||||
Reference in New Issue
Block a user