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