From 35d676e76e415fd3defc0ff4f0551b5b8291cedd Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Thu, 9 Apr 2026 12:20:03 -0700 Subject: [PATCH 1/6] Add real-time autocomplete suggestions when typing in the place search field, with Google Places Autocomplete API and Nominatim fallback. - Add POST /api/maps/autocomplete route and autocompletePlaces service - Add mapsApi.autocomplete client method - Add debounced autocomplete dropdown to PlaceFormModal with keyboard navigation (arrow keys, enter, escape) and mouse selection - Use place details API to populate form fields on suggestion selection - Derive location bias from existing trip places for better results - Extract Google Maps URL regex to shared constant --- client/src/api/client.ts | 2 + .../src/components/Planner/PlaceFormModal.tsx | 160 ++++++++++++++++-- server/src/routes/maps.ts | 30 ++++ server/src/services/mapsService.ts | 87 ++++++++++ 4 files changed, 261 insertions(+), 18 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 5848c5c4..81b17d13 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -210,6 +210,8 @@ export const addonsApi = { export const mapsApi = { search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), + autocomplete: (input: string, lang?: string, locationBias?: { lat: number; lng: number }) => + apiClient.post('/maps/autocomplete', { input, lang, locationBias }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 3d30f1a0..0d1f287a 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -25,6 +25,8 @@ interface PlaceFormData { website: string } +const GOOGLE_MAPS_URL_RE = /^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i + const DEFAULT_FORM: PlaceFormData = { name: '', description: '', @@ -65,6 +67,10 @@ export default function PlaceFormModal({ const [isSaving, setIsSaving] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const fileRef = useRef(null) + const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([]) + const [acHighlight, setAcHighlight] = useState(-1) + const acDebounceRef = useRef | null>(null) + const [acTrigger, setAcTrigger] = useState(0) const toast = useToast() const { t, language } = useTranslation() const { hasMapsKey } = useAuthStore() @@ -101,6 +107,43 @@ export default function PlaceFormModal({ setPendingFiles([]) }, [place, prefillCoords, isOpen]) + // Derive location bias from the trip's existing places + const places = useTripStore((s) => s.places) + const locationBias = useMemo(() => { + const firstWithCoords = places?.find((p) => p.lat != null && p.lng != null) + if (!firstWithCoords) return undefined + const lat = Number(firstWithCoords.lat) + const lng = Number(firstWithCoords.lng) + return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : undefined + }, [places]) + + // Debounced autocomplete + useEffect(() => { + if (acDebounceRef.current) clearTimeout(acDebounceRef.current) + + const trimmed = mapsSearch.trim() + if (trimmed.length < 2 || GOOGLE_MAPS_URL_RE.test(trimmed)) { + setAcSuggestions([]) + setAcHighlight(-1) + return + } + + acDebounceRef.current = setTimeout(async () => { + try { + const result = await mapsApi.autocomplete(trimmed, language, locationBias) + setAcSuggestions(result.suggestions || []) + setAcHighlight(-1) + } catch (err) { + console.error('Autocomplete failed:', err) + setAcSuggestions([]) + } + }, 300) + + return () => { + if (acDebounceRef.current) clearTimeout(acDebounceRef.current) + } + }, [mapsSearch, language, locationBias, acTrigger]) + const handleChange = (field, value) => { setForm(prev => ({ ...prev, [field]: value })) } @@ -111,7 +154,7 @@ export default function PlaceFormModal({ try { // Detect Google Maps URLs and resolve them directly const trimmed = mapsSearch.trim() - if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) { + if (trimmed.match(GOOGLE_MAPS_URL_RE)) { const resolved = await mapsApi.resolveUrl(trimmed) if (resolved.lat && resolved.lng) { setForm(prev => ({ @@ -152,6 +195,55 @@ export default function PlaceFormModal({ setMapsSearch('') } + const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => { + setAcSuggestions([]) + setAcHighlight(-1) + const previousSearch = mapsSearch + setMapsSearch('') + setIsSearchingMaps(true) + try { + const result = await mapsApi.details(suggestion.placeId, language) + if (result.place) { + handleSelectMapsResult(result.place) + } else { + setMapsSearch(previousSearch) + toast.error(t('places.mapsSearchError')) + } + } catch (err) { + console.error('Failed to fetch place details:', err) + setMapsSearch(previousSearch) + toast.error(t('places.mapsSearchError')) + } finally { + setIsSearchingMaps(false) + } + } + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (acSuggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setAcHighlight(prev => (prev + 1) % acSuggestions.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (acHighlight >= 0) { + handleSelectSuggestion(acSuggestions[acHighlight]) + } else { + setAcSuggestions([]) + handleMapsSearch() + } + } else if (e.key === 'Escape') { + setAcSuggestions([]) + setAcHighlight(-1) + } + } else if (e.key === 'Enter') { + e.preventDefault() + handleMapsSearch() + } + } + const handleCreateCategory = async () => { if (!newCategoryName.trim()) return try { @@ -229,24 +321,56 @@ export default function PlaceFormModal({ {t('places.osmActive')}

)} -
- setMapsSearch(e.target.value)} - onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())} - placeholder={t('places.mapsSearchPlaceholder')} - className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white" - /> - +
+
+ setMapsSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + onBlur={() => setTimeout(() => setAcSuggestions([]), 150)} + onFocus={() => { + if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) { + setAcTrigger(prev => prev + 1) + } + }} + placeholder={t('places.mapsSearchPlaceholder')} + className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white" + /> + +
+ + {/* Autocomplete dropdown */} + {acSuggestions.length > 0 && ( +
+ {acSuggestions.map((s, idx) => ( + + ))} +
+ )}
+ + {/* Search results (populated after full search) */} {mapsResults.length > 0 && (
{mapsResults.map((result, idx) => ( diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 3d135efa..1dcfcdd3 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -7,6 +7,7 @@ import { getPlacePhoto, reverseGeocode, resolveGoogleMapsUrl, + autocompletePlaces, } from '../services/mapsService'; const router = express.Router(); @@ -29,6 +30,35 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { } }); +// POST /autocomplete +router.post('/autocomplete', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { input, lang, locationBias } = req.body; + + if (!input || typeof input !== 'string') { + return res.status(400).json({ error: 'Input is required' }); + } + + if (locationBias && (!Number.isFinite(locationBias.lat) || !Number.isFinite(locationBias.lng))) { + return res.status(400).json({ error: 'Invalid locationBias: lat and lng must be finite numbers' }); + } + + try { + const result = await autocompletePlaces( + authReq.user.id, + input, + lang as string, + locationBias as { lat: number; lng: number } | undefined, + ); + res.json(result); + } catch (err: unknown) { + const status = (err as { status?: number }).status || 500; + const message = err instanceof Error ? err.message : 'Autocomplete error'; + console.error('Maps autocomplete error:', err); + res.status(status).json({ error: message }); + } +}); + // GET /details/:placeId router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 3229c1b8..849fff66 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -32,6 +32,16 @@ interface GooglePlaceResult { types?: string[]; } +interface GoogleAutocompleteSuggestion { + placePrediction?: { + placeId: string; + structuredFormat?: { + mainText?: { text: string }; + secondaryText?: { text: string }; + }; + }; +} + interface GooglePlaceDetails extends GooglePlaceResult { userRatingCount?: number; regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean }; @@ -303,6 +313,83 @@ export async function searchPlaces(userId: number, query: string, lang?: string) return { places, source: 'google' }; } +// ── Autocomplete (Google or Nominatim fallback) ───────────────────────────── + +export async function autocompletePlaces( + userId: number, + input: string, + lang?: string, + locationBias?: { lat: number; lng: number }, +): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { + const apiKey = getMapsKey(userId); + + if (!apiKey) { + return autocompleteNominatim(input, lang); + } + + const body: Record = { + input, + languageCode: lang || 'en', + }; + if (locationBias) { + body.locationBias = { + circle: { + center: { latitude: locationBias.lat, longitude: locationBias.lng }, + radius: 50000.0, + }, + }; + } + + const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + }, + body: JSON.stringify(body), + }); + + const data = await response.json() as { suggestions?: GoogleAutocompleteSuggestion[]; error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const suggestions = (data.suggestions || []) + .filter((s) => s.placePrediction) + .slice(0, 5) + .map((s) => ({ + placeId: s.placePrediction!.placeId, + mainText: s.placePrediction!.structuredFormat?.mainText?.text || '', + secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '', + })); + + return { suggestions, source: 'google' }; +} + +async function autocompleteNominatim( + input: string, + lang?: string, +): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { + try { + const places = await searchNominatim(input, lang); + const suggestions = places.slice(0, 5).map((p) => { + const parts = (p.address || '').split(',').map((s) => s.trim()); + return { + placeId: p.osm_id || '', + mainText: p.name || parts[0] || '', + secondaryText: parts.slice(1).join(', '), + }; + }); + return { suggestions, source: 'nominatim' }; + } catch (err) { + console.error('Nominatim autocomplete failed:', err); + return { suggestions: [], source: 'nominatim' }; + } +} + // ── Place details (Google or OSM) ──────────────────────────────────────────── export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record }> { From 583ac6d4d9b823a70dfbe71c6cf18d6a4a9333e4 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Thu, 9 Apr 2026 16:02:10 -0700 Subject: [PATCH 2/6] Add tests for mapsApi.autocomplete and autocompletePlaces service interactions --- client/tests/integration/api/client.test.ts | 51 +++++ server/tests/integration/maps.test.ts | 106 ++++++++++ .../tests/unit/services/mapsService.test.ts | 196 ++++++++++++++++++ 3 files changed, 353 insertions(+) diff --git a/client/tests/integration/api/client.test.ts b/client/tests/integration/api/client.test.ts index 0b71af53..f3121c10 100644 --- a/client/tests/integration/api/client.test.ts +++ b/client/tests/integration/api/client.test.ts @@ -902,3 +902,54 @@ describe('API namespace smoke tests', () => { await expect(backupApi.create()).resolves.toMatchObject({ filename: 'backup.zip' }); }); }); + +describe('mapsApi', () => { + it('FE-MAPS-001: mapsApi.autocomplete sends input, lang, and locationBias', async () => { + let capturedBody: any = null; + + server.use( + http.post('/api/maps/autocomplete', async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json({ + suggestions: [{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }], + source: 'google', + }); + }) + ); + + const result = await mapsApi.autocomplete('Par', 'fr', { lat: 48.8, lng: 2.3 }); + + expect(capturedBody).toEqual({ + input: 'Par', + lang: 'fr', + locationBias: { lat: 48.8, lng: 2.3 }, + }); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].mainText).toBe('Paris'); + expect(result.source).toBe('google'); + }); + + it('FE-MAPS-002: mapsApi.autocomplete works without optional params', async () => { + server.use( + http.post('/api/maps/autocomplete', async ({ request }) => { + const body: any = await request.json(); + expect(body.lang).toBeUndefined(); + expect(body.locationBias).toBeUndefined(); + return HttpResponse.json({ suggestions: [], source: 'nominatim' }); + }) + ); + + const result = await mapsApi.autocomplete('test'); + expect(result.suggestions).toEqual([]); + }); + + it('FE-MAPS-003: mapsApi.autocomplete rejects on server error', async () => { + server.use( + http.post('/api/maps/autocomplete', () => { + return HttpResponse.json({ error: 'Rate limited' }, { status: 429 }); + }) + ); + + await expect(mapsApi.autocomplete('test')).rejects.toThrow(); + }); +}); diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index 91559e20..d89893a1 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -44,6 +44,7 @@ vi.mock('../../src/config', () => ({ // URLs that look internal); individual tests override with mockResolvedValueOnce. vi.mock('../../src/services/mapsService', () => ({ searchPlaces: vi.fn(), + autocompletePlaces: vi.fn(), getPlaceDetails: vi.fn(), getPlacePhoto: vi.fn(), reverseGeocode: vi.fn(), @@ -278,3 +279,108 @@ describe('Maps happy paths (mocked service)', () => { expect(res.body.address).toBeNull(); }); }); + +describe('Maps autocomplete', () => { + it('MAPS-009 — POST /maps/autocomplete without auth returns 401', async () => { + const res = await request(app) + .post('/api/maps/autocomplete') + .send({ input: 'Paris' }); + expect(res.status).toBe(401); + }); + + it('MAPS-010 — POST /maps/autocomplete without input returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({}); + expect(res.status).toBe(400); + }); + + it('MAPS-011 — POST /maps/autocomplete with non-string input returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 123 }); + expect(res.status).toBe(400); + }); + + it('MAPS-012 — POST /maps/autocomplete with invalid locationBias returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'Paris', locationBias: { lat: NaN, lng: 2.3 } }); + expect(res.status).toBe(400); + }); + + it('MAPS-013 — POST /maps/autocomplete returns suggestions from service', async () => { + const { user } = createUser(testDb); + vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({ + suggestions: [ + { placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }, + ], + source: 'google', + }); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'Paris' }); + + expect(res.status).toBe(200); + expect(res.body.suggestions).toHaveLength(1); + expect(res.body.suggestions[0].mainText).toBe('Paris'); + expect(res.body.source).toBe('google'); + }); + + it('MAPS-014 — POST /maps/autocomplete passes lang and locationBias to service', async () => { + const { user } = createUser(testDb); + vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({ + suggestions: [], + source: 'google', + }); + + await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'test', lang: 'fr', locationBias: { lat: 48.8, lng: 2.3 } }); + + expect(mapsService.autocompletePlaces).toHaveBeenCalledWith( + user.id, + 'test', + 'fr', + { lat: 48.8, lng: 2.3 }, + ); + }); + + it('MAPS-015 — autocomplete service error propagates correct status', async () => { + const { user } = createUser(testDb); + const err = Object.assign(new Error('Rate limited'), { status: 429 }); + vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(err); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'test' }); + + expect(res.status).toBe(429); + expect(res.body.error).toBe('Rate limited'); + }); + + it('MAPS-016 — autocomplete service error without status returns 500', async () => { + const { user } = createUser(testDb); + vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(new Error('Unknown')); + + const res = await request(app) + .post('/api/maps/autocomplete') + .set('Cookie', authCookie(user.id)) + .send({ input: 'test' }); + + expect(res.status).toBe(500); + }); +}); diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 6dd98dd0..28118168 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -709,6 +709,202 @@ describe('searchPlaces (fetch stubbed)', () => { }); }); +// ── autocompletePlaces (fetch stubbed) ────────────────────────────────────── + +describe('autocompletePlaces (fetch stubbed)', () => { + it('MAPS-081: uses Nominatim when user has no API key', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, Île-de-France, France', name: 'Paris' }, + ], + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(999, 'Paris'); + expect(result.source).toBe('nominatim'); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].mainText).toBe('Paris'); + expect(result.suggestions[0].placeId).toBe('node:1'); + }); + + it('MAPS-082: uses Google when user has an API key', async () => { + mockDbGet + .mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }) + .mockReturnValueOnce(null); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { + placePrediction: { + placeId: 'ChIJ1234', + structuredFormat: { + mainText: { text: 'Eiffel Tower' }, + secondaryText: { text: 'Paris, France' }, + }, + }, + }, + ], + }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'Eiffel'); + expect(result.source).toBe('google'); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].placeId).toBe('ChIJ1234'); + expect(result.suggestions[0].mainText).toBe('Eiffel Tower'); + expect(result.suggestions[0].secondaryText).toBe('Paris, France'); + }); + + it('MAPS-083: throws with Google error status when API returns non-ok', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: { message: 'API key invalid' } }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({ + message: 'API key invalid', + status: 403, + }); + }); + + it('MAPS-084: throws generic message when Google error has no message', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: {} }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({ + message: 'Google Places Autocomplete error', + status: 500, + }); + }); + + it('MAPS-085: returns empty suggestions when Google returns no results', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'very obscure place'); + expect(result.source).toBe('google'); + expect(result.suggestions).toHaveLength(0); + }); + + it('MAPS-086: filters out suggestions without placePrediction', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } }, + { queryPrediction: { text: 'some query' } }, + { placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } }, + ], + }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'test'); + expect(result.suggestions).toHaveLength(2); + expect(result.suggestions[0].placeId).toBe('A'); + expect(result.suggestions[1].placeId).toBe('B'); + }); + + it('MAPS-087: limits results to 5 suggestions', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + const manySuggestions = Array.from({ length: 10 }, (_, i) => ({ + placePrediction: { + placeId: `id-${i}`, + structuredFormat: { mainText: { text: `Place ${i}` } }, + }, + })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: manySuggestions }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'test'); + expect(result.suggestions).toHaveLength(5); + }); + + it('MAPS-088: includes locationBias in Google request when provided', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await autocompletePlaces(1, 'test', 'en', { lat: 48.8, lng: 2.3 }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.locationBias).toEqual({ + circle: { + center: { latitude: 48.8, longitude: 2.3 }, + radius: 50000.0, + }, + }); + }); + + it('MAPS-089: omits locationBias from Google request when not provided', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + await autocompletePlaces(1, 'test', 'en'); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.locationBias).toBeUndefined(); + }); + + it('MAPS-090: handles missing structuredFormat fields gracefully', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { placePrediction: { placeId: 'sparse-id' } }, + ], + }), + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(1, 'sparse'); + expect(result.suggestions[0].placeId).toBe('sparse-id'); + expect(result.suggestions[0].mainText).toBe(''); + expect(result.suggestions[0].secondaryText).toBe(''); + }); + + it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(999, 'fail'); + expect(result.source).toBe('nominatim'); + expect(result.suggestions).toHaveLength(0); + }); + + it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { osm_type: 'way', osm_id: '42', lat: '51.5', lon: '-0.1', display_name: 'Big Ben, Westminster, London, UK', name: 'Big Ben' }, + ], + })); + const { autocompletePlaces } = await import('../../../src/services/mapsService'); + const result = await autocompletePlaces(999, 'Big Ben'); + expect(result.suggestions[0].mainText).toBe('Big Ben'); + expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK'); + }); +}); + // ── getPlaceDetails (fetch stubbed) ───────────────────────────────────────── describe('getPlaceDetails (fetch stubbed)', () => { From 8f1445e6df7a30c189f297eb1e3864e8e860fcfe Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Thu, 9 Apr 2026 16:11:05 -0700 Subject: [PATCH 3/6] Fix too permissive regex for Google Maps --- client/src/components/Planner/PlaceFormModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 0d1f287a..0e581d28 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -25,7 +25,7 @@ interface PlaceFormData { website: string } -const GOOGLE_MAPS_URL_RE = /^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i +const GOOGLE_MAPS_URL_RE = /^https?:\/\/(www\.)?(google\.[a-z]{2,3}(\.[a-z]{2})?\/maps|maps\.google\.[a-z]{2,3}(\.[a-z]{2})?(\/|$)|maps\.app\.goo\.gl|goo\.gl\/maps)/i const DEFAULT_FORM: PlaceFormData = { name: '', From 4a16442db096eeb35e38593c0e2185e74c26f689 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Thu, 9 Apr 2026 17:06:41 -0700 Subject: [PATCH 4/6] Replace Google Maps URL regex with a safer utility function --- .../src/components/Planner/PlaceFormModal.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 0e581d28..473962ec 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -25,7 +25,24 @@ interface PlaceFormData { website: string } -const GOOGLE_MAPS_URL_RE = /^https?:\/\/(www\.)?(google\.[a-z]{2,3}(\.[a-z]{2})?\/maps|maps\.google\.[a-z]{2,3}(\.[a-z]{2})?(\/|$)|maps\.app\.goo\.gl|goo\.gl\/maps)/i +function isGoogleMapsUrl(input: string): boolean { + try { + const { hostname, pathname } = new URL(input.trim()) + const h = hostname.toLowerCase() + // maps.app.goo.gl, goo.gl/maps + if (h === 'maps.app.goo.gl') return true + if (h === 'goo.gl' && pathname.startsWith('/maps')) return true + // maps.google.* (e.g. maps.google.com, maps.google.co.uk) + // Must be maps.google. or maps.google.. — reject maps.google.evil.com + if (/^maps\.google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(h)) return true + // google.*/maps (e.g. google.com/maps, www.google.co.uk/maps) + const bare = h.startsWith('www.') ? h.slice(4) : h + if (/^google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(bare) && pathname.startsWith('/maps')) return true + return false + } catch { + return false + } +} const DEFAULT_FORM: PlaceFormData = { name: '', @@ -122,7 +139,7 @@ export default function PlaceFormModal({ if (acDebounceRef.current) clearTimeout(acDebounceRef.current) const trimmed = mapsSearch.trim() - if (trimmed.length < 2 || GOOGLE_MAPS_URL_RE.test(trimmed)) { + if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) { setAcSuggestions([]) setAcHighlight(-1) return @@ -154,7 +171,7 @@ export default function PlaceFormModal({ try { // Detect Google Maps URLs and resolve them directly const trimmed = mapsSearch.trim() - if (trimmed.match(GOOGLE_MAPS_URL_RE)) { + if (isGoogleMapsUrl(trimmed)) { const resolved = await mapsApi.resolveUrl(trimmed) if (resolved.lat && resolved.lng) { setForm(prev => ({ From 7fca16d866dd744f1ec77ad2b6ee5b61a8e200c7 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Mon, 13 Apr 2026 07:53:40 -0700 Subject: [PATCH 5/6] Switch location bias from a point to a bounding box for improved autocomplete accuracy and validation. --- client/package-lock.json | 147 +++--------------- client/src/api/client.ts | 2 +- .../src/components/Planner/PlaceFormModal.tsx | 29 +++- client/tests/integration/api/client.test.ts | 4 +- server/package-lock.json | 10 ++ server/src/routes/maps.ts | 11 +- server/src/services/mapsService.ts | 8 +- server/tests/integration/maps.test.ts | 6 +- .../tests/unit/services/mapsService.test.ts | 8 +- 9 files changed, 80 insertions(+), 145 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index f1ac488c..ff9ad699 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -154,6 +154,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1789,6 +1790,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1837,34 +1839,11 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1872,7 +1851,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1983,9 +1961,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2003,9 +1978,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2023,9 +1995,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2060,9 +2029,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2097,9 +2063,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2123,9 +2086,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2149,9 +2109,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2198,9 +2155,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2855,9 +2809,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2875,9 +2826,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2895,9 +2843,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2915,9 +2860,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2935,9 +2877,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2955,9 +2894,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3218,9 +3154,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3235,9 +3168,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3252,9 +3182,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3269,9 +3196,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3286,9 +3210,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3303,9 +3224,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3320,9 +3238,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3337,9 +3252,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3354,9 +3266,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3371,9 +3280,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3388,9 +3294,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3644,8 +3547,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3786,6 +3688,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3797,6 +3700,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4017,6 +3921,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4044,7 +3949,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4455,6 +4359,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5168,8 +5073,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -6801,6 +6705,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6967,7 +6872,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/leaflet.markercluster": { "version": "1.5.3", @@ -6994,6 +6900,7 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -7131,9 +7038,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7155,9 +7059,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7179,9 +7080,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7203,9 +7101,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7368,7 +7263,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8788,6 +8682,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8949,7 +8844,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8964,8 +8858,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -9049,6 +8942,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9061,6 +8955,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9097,6 +8992,7 @@ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", "license": "Hippocratic-2.1", + "peer": true, "dependencies": { "@react-leaflet/core": "^2.1.0" }, @@ -10386,6 +10282,7 @@ "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -10493,6 +10390,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10716,6 +10614,7 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11034,6 +10933,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11569,6 +11469,7 @@ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", @@ -11711,6 +11612,7 @@ "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -12106,6 +12008,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81b17d13..c3e0869b 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -210,7 +210,7 @@ export const addonsApi = { export const mapsApi = { search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), - autocomplete: (input: string, lang?: string, locationBias?: { lat: number; lng: number }) => + autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }) => apiClient.post('/maps/autocomplete', { input, lang, locationBias }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 473962ec..83aed50d 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -124,14 +124,31 @@ export default function PlaceFormModal({ setPendingFiles([]) }, [place, prefillCoords, isOpen]) - // Derive location bias from the trip's existing places + // Derive location bias bounding box from the trip's existing places const places = useTripStore((s) => s.places) const locationBias = useMemo(() => { - const firstWithCoords = places?.find((p) => p.lat != null && p.lng != null) - if (!firstWithCoords) return undefined - const lat = Number(firstWithCoords.lat) - const lng = Number(firstWithCoords.lng) - return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : undefined + const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null) + if (withCoords.length === 0) return undefined + + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity + for (const p of withCoords) { + const lat = Number(p.lat), lng = Number(p.lng) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + if (lng < minLng) minLng = lng + if (lng > maxLng) maxLng = lng + } + if (!Number.isFinite(minLat)) return undefined + + // Skip bias if the bounding box is too large (~500 km diagonal) + const dlat = maxLat - minLat + const dlng = maxLng - minLng + const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180) + const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2) + if (diagKm > 500) return undefined + + return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } } }, [places]) // Debounced autocomplete diff --git a/client/tests/integration/api/client.test.ts b/client/tests/integration/api/client.test.ts index f3121c10..24c2134d 100644 --- a/client/tests/integration/api/client.test.ts +++ b/client/tests/integration/api/client.test.ts @@ -917,12 +917,12 @@ describe('mapsApi', () => { }) ); - const result = await mapsApi.autocomplete('Par', 'fr', { lat: 48.8, lng: 2.3 }); + const result = await mapsApi.autocomplete('Par', 'fr', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } }); expect(capturedBody).toEqual({ input: 'Par', lang: 'fr', - locationBias: { lat: 48.8, lng: 2.3 }, + locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } }, }); expect(result.suggestions).toHaveLength(1); expect(result.suggestions[0].mainText).toBe('Paris'); diff --git a/server/package-lock.json b/server/package-lock.json index de52f174..3bdb86f2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1589,6 +1589,7 @@ "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2179,6 +2180,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -3158,6 +3160,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3662,6 +3665,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -5768,6 +5772,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5842,6 +5847,7 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -5997,6 +6003,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6138,6 +6145,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6151,6 +6159,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -6414,6 +6423,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 1dcfcdd3..6b010f44 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -39,8 +39,13 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) = return res.status(400).json({ error: 'Input is required' }); } - if (locationBias && (!Number.isFinite(locationBias.lat) || !Number.isFinite(locationBias.lng))) { - return res.status(400).json({ error: 'Invalid locationBias: lat and lng must be finite numbers' }); + if (locationBias) { + const { low, high } = locationBias; + if (!low || !high + || !Number.isFinite(low.lat) || !Number.isFinite(low.lng) + || !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) { + return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' }); + } } try { @@ -48,7 +53,7 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) = authReq.user.id, input, lang as string, - locationBias as { lat: number; lng: number } | undefined, + locationBias as { low: { lat: number; lng: number }; high: { lat: number; lng: number } } | undefined, ); res.json(result); } catch (err: unknown) { diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 849fff66..119089cb 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -319,7 +319,7 @@ export async function autocompletePlaces( userId: number, input: string, lang?: string, - locationBias?: { lat: number; lng: number }, + locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { const apiKey = getMapsKey(userId); @@ -333,9 +333,9 @@ export async function autocompletePlaces( }; if (locationBias) { body.locationBias = { - circle: { - center: { latitude: locationBias.lat, longitude: locationBias.lng }, - radius: 50000.0, + rectangle: { + low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng }, + high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng }, }, }; } diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index d89893a1..6e7c4223 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -314,7 +314,7 @@ describe('Maps autocomplete', () => { const res = await request(app) .post('/api/maps/autocomplete') .set('Cookie', authCookie(user.id)) - .send({ input: 'Paris', locationBias: { lat: NaN, lng: 2.3 } }); + .send({ input: 'Paris', locationBias: { low: { lat: NaN, lng: 2.3 }, high: { lat: 49, lng: 3 } } }); expect(res.status).toBe(400); }); @@ -348,13 +348,13 @@ describe('Maps autocomplete', () => { await request(app) .post('/api/maps/autocomplete') .set('Cookie', authCookie(user.id)) - .send({ input: 'test', lang: 'fr', locationBias: { lat: 48.8, lng: 2.3 } }); + .send({ input: 'test', lang: 'fr', locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } } }); expect(mapsService.autocompletePlaces).toHaveBeenCalledWith( user.id, 'test', 'fr', - { lat: 48.8, lng: 2.3 }, + { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } }, ); }); diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 28118168..9ce9c79d 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -840,14 +840,14 @@ describe('autocompletePlaces (fetch stubbed)', () => { }); vi.stubGlobal('fetch', fetchMock); const { autocompletePlaces } = await import('../../../src/services/mapsService'); - await autocompletePlaces(1, 'test', 'en', { lat: 48.8, lng: 2.3 }); + await autocompletePlaces(1, 'test', 'en', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } }); expect(fetchMock).toHaveBeenCalledOnce(); const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.locationBias).toEqual({ - circle: { - center: { latitude: 48.8, longitude: 2.3 }, - radius: 50000.0, + rectangle: { + low: { latitude: 48.5, longitude: 2.0 }, + high: { latitude: 49.0, longitude: 2.8 }, }, }); }); From 1a51f8e3e1ebef3fac1b05d3ad37d5c794ca340f Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Mon, 13 Apr 2026 08:28:34 -0700 Subject: [PATCH 6/6] =?UTF-8?q?Add=20translations=20for=20"Loading=20place?= =?UTF-8?q?=20details=E2=80=A6"=20and=20improve=20place=20search=20functio?= =?UTF-8?q?nality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrate a loading spinner for "Name" input field during place search. - Enhance OpenStreetMap place detail retrieval with Nominatim lookup. - Update `authStore` to track Google Maps API key presence. --- .../src/components/Planner/PlaceFormModal.tsx | 26 +++++++---- client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/pl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/i18n/translations/zhTw.ts | 1 + client/src/store/authStore.ts | 4 ++ server/src/services/mapsService.ts | 46 +++++++++++++++++-- 17 files changed, 78 insertions(+), 12 deletions(-) diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 83aed50d..6cd71f8d 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -6,7 +6,7 @@ import { useAuthStore } from '../../store/authStore' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' -import { Search, Paperclip, X, AlertTriangle } from 'lucide-react' +import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import type { Place, Category, Assignment } from '../../types' @@ -234,6 +234,7 @@ export default function PlaceFormModal({ setAcHighlight(-1) const previousSearch = mapsSearch setMapsSearch('') + setForm(prev => ({ ...prev, name: suggestion.mainText })) setIsSearchingMaps(true) try { const result = await mapsApi.details(suggestion.placeId, language) @@ -425,14 +426,21 @@ export default function PlaceFormModal({ {/* Name */}
- handleChange('name', e.target.value)} - required - placeholder={t('places.formNamePlaceholder')} - className="form-input" - /> +
+ handleChange('name', e.target.value)} + required + placeholder={t('places.formNamePlaceholder')} + className="form-input" + /> + {isSearchingMaps && ( +
+ +
+ )} +
{/* Description */} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index cdcac563..5251b244 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -855,6 +855,7 @@ const ar: Record = { 'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...', 'places.mapsSearchPlaceholder': 'ابحث عن أماكن...', 'places.mapsSearchError': 'فشل البحث عن المكان.', + 'places.loadingDetails': 'جارٍ تحميل تفاصيل المكان…', 'places.osmHint': 'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.', 'places.osmActive': 'البحث عبر OpenStreetMap (بدون صور أو تقييمات أو ساعات عمل). أضف مفتاح Google API في الإعدادات لبيانات موسعة.', 'places.categoryCreateError': 'فشل إنشاء الفئة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index d3b11e66..40d24bfe 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -837,6 +837,7 @@ const br: Record = { 'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...', 'places.mapsSearchPlaceholder': 'Buscar lugares...', 'places.mapsSearchError': 'Falha na busca de lugares.', + 'places.loadingDetails': 'Carregando detalhes do lugar…', 'places.osmHint': 'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.', 'places.osmActive': 'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.', 'places.categoryCreateError': 'Falha ao criar categoria', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 130f9623..086de3bc 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -853,6 +853,7 @@ const cs: Record = { 'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...', 'places.mapsSearchPlaceholder': 'Hledat místa...', 'places.mapsSearchError': 'Hledání místa se nezdařilo.', + 'places.loadingDetails': 'Načítání podrobností místa…', 'places.osmHint': 'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.', 'places.osmActive': 'Hledání přes OpenStreetMap.', 'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index c9ebf453..811c7682 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -853,6 +853,7 @@ const de: Record = { 'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...', 'places.mapsSearchPlaceholder': 'Ortssuche...', 'places.mapsSearchError': 'Ortssuche fehlgeschlagen.', + 'places.loadingDetails': 'Ortsdetails werden geladen…', 'places.osmHint': 'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.', 'places.osmActive': 'Suche via OpenStreetMap (ohne Bilder, Bewertungen & Öffnungszeiten). Google API Key in den Einstellungen hinterlegen für erweiterte Daten.', 'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 2cb895a1..ad1a4824 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -872,6 +872,7 @@ const en: Record = { 'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...', 'places.mapsSearchPlaceholder': 'Search places...', 'places.mapsSearchError': 'Place search failed.', + 'places.loadingDetails': 'Loading place details…', 'places.osmHint': 'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.', 'places.osmActive': 'Search via OpenStreetMap (no photos, ratings or opening hours). Add a Google API key in Settings for enhanced data.', 'places.categoryCreateError': 'Failed to create category', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 41219432..1ce404e7 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -828,6 +828,7 @@ const es: Record = { 'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...', 'places.mapsSearchPlaceholder': 'Buscar lugares...', 'places.mapsSearchError': 'La búsqueda de lugares falló.', + 'places.loadingDetails': 'Cargando detalles del lugar…', 'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.', 'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.', 'places.categoryCreateError': 'No se pudo crear la categoría', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index cbc2e09c..c3825ab7 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -852,6 +852,7 @@ const fr: Record = { 'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…', 'places.mapsSearchPlaceholder': 'Rechercher des lieux…', 'places.mapsSearchError': 'La recherche de lieu a échoué.', + 'places.loadingDetails': 'Chargement des détails du lieu…', 'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.', 'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.', 'places.categoryCreateError': 'Impossible de créer la catégorie', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 40ce49a2..3046d2eb 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -853,6 +853,7 @@ const hu: Record = { 'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...', 'places.mapsSearchPlaceholder': 'Helyek keresése...', 'places.mapsSearchError': 'Helykeresés sikertelen.', + 'places.loadingDetails': 'Hely adatainak betöltése…', 'places.osmHint': 'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.', 'places.osmActive': 'Keresés OpenStreetMap-en keresztül (képek, értékelések és nyitvatartás nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.', 'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index d5449ff8..60ffb247 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -853,6 +853,7 @@ const it: Record = { 'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...', 'places.mapsSearchPlaceholder': 'Cerca luoghi...', 'places.mapsSearchError': 'Impossibile cercare i luoghi.', + 'places.loadingDetails': 'Caricamento dettagli del luogo…', 'places.osmHint': 'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.', 'places.osmActive': 'Ricerca tramite OpenStreetMap (senza foto, valutazioni o orari di apertura). Aggiungi una chiave API Google nelle Impostazioni per dati avanzati.', 'places.categoryCreateError': 'Impossibile creare la categoria', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 2e7495da..50c174dc 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -852,6 +852,7 @@ const nl: Record = { 'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...', 'places.mapsSearchPlaceholder': 'Plaatsen zoeken...', 'places.mapsSearchError': 'Zoeken naar plaatsen mislukt.', + 'places.loadingDetails': 'Plaatsgegevens laden…', 'places.osmHint': 'Zoeken via OpenStreetMap (geen foto\'s, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.', 'places.osmActive': 'Zoeken via OpenStreetMap (geen foto\'s, beoordelingen of openingstijden). Voeg een Google API-sleutel toe in Instellingen voor uitgebreide gegevens.', 'places.categoryCreateError': 'Categorie aanmaken mislukt', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 4f60b983..5870f5c9 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -811,6 +811,7 @@ const pl: Record = { 'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...', 'places.mapsSearchPlaceholder': 'Szukaj miejsc...', 'places.mapsSearchError': 'Nie udało się wyszukać miejsca.', + 'places.loadingDetails': 'Ładowanie szczegółów miejsca…', 'places.osmHint': 'Korzystając z OpenStreetMap (brak zdjęć, godzin otwarcia czy ocen). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.', 'places.osmActive': 'Szukaj przez OpenStreetMap (brak zdjęć, ocen czy godzin otwarcia). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.', 'places.categoryCreateError': 'Nie udało się utworzyć kategorii', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 18001fc9..a9624ad7 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -852,6 +852,7 @@ const ru: Record = { 'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...', 'places.mapsSearchPlaceholder': 'Поиск мест...', 'places.mapsSearchError': 'Ошибка поиска мест.', + 'places.loadingDetails': 'Загрузка данных о месте…', 'places.osmHint': 'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.', 'places.osmActive': 'Поиск через OpenStreetMap (без фото, рейтингов и часов работы). Добавьте API-ключ Google в настройках для расширенных данных.', 'places.categoryCreateError': 'Не удалось создать категорию', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index d0af81d3..11326296 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -852,6 +852,7 @@ const zh: Record = { 'places.reservationNotesPlaceholder': '预订备注、确认号...', 'places.mapsSearchPlaceholder': '搜索地点...', 'places.mapsSearchError': '地点搜索失败。', + 'places.loadingDetails': '正在加载地点详情…', 'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。', 'places.osmActive': '通过 OpenStreetMap 搜索(无照片、评分或营业时间)。在设置中添加 Google API 密钥以获取增强数据。', 'places.categoryCreateError': '创建分类失败', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 86fa1418..9f270f58 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -832,6 +832,7 @@ const zhTw: Record = { 'places.reservationNotesPlaceholder': '預訂備註、確認號...', 'places.mapsSearchPlaceholder': '搜尋地點...', 'places.mapsSearchError': '地點搜尋失敗。', + 'places.loadingDetails': '正在載入地點詳情…', 'places.osmHint': '使用 OpenStreetMap 搜尋(無照片、營業時間或評分)。在設定中新增 Google API 金鑰以獲取完整資訊。', 'places.osmActive': '透過 OpenStreetMap 搜尋(無照片、評分或營業時間)。在設定中新增 Google API 金鑰以獲取增強資料。', 'places.categoryCreateError': '建立分類失敗', diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index d7a11de1..2fb289ad 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -178,6 +178,7 @@ export const useAuthStore = create((set, get) => ({ await authApi.updateMapsKey(key) set((state) => ({ user: state.user ? { ...state.user, maps_api_key: key || null } : null, + hasMapsKey: !!key, })) } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error saving API key')) @@ -188,6 +189,9 @@ export const useAuthStore = create((set, get) => ({ try { const data = await authApi.updateApiKeys(keys) set({ user: data.user }) + if ('maps_api_key' in keys) { + set({ hasMapsKey: !!keys.maps_api_key }) + } } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error saving API keys')) } diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 119089cb..2f537850 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -115,6 +115,34 @@ export async function searchNominatim(query: string, lang?: string) { })); } +// ── Nominatim lookup (by OSM ID) ──────────────────────────────────────────── + +export async function lookupNominatim(osmType: string, osmId: string, lang?: string): Promise<{ + name: string; address: string; lat: number | null; lng: number | null; +} | null> { + const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R + const params = new URLSearchParams({ + osm_ids: `${typePrefix}${osmId}`, + format: 'json', + 'accept-language': lang || 'en', + }); + try { + const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, { + headers: { 'User-Agent': UA }, + }); + if (!res.ok) return null; + const data = await res.json() as NominatimResult[]; + const item = data[0]; + if (!item) return null; + return { + name: item.name || item.display_name?.split(',')[0] || '', + address: item.display_name || '', + lat: parseFloat(item.lat) || null, + lng: parseFloat(item.lon) || null, + }; + } catch { return null; } +} + // ── Overpass API (OSM details) ─────────────────────────────────────────────── export async function fetchOverpassDetails(osmType: string, osmId: string): Promise { @@ -396,9 +424,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st // OSM details: placeId is "node:123456" or "way:123456" etc. if (placeId.includes(':')) { const [osmType, osmId] = placeId.split(':'); - const element = await fetchOverpassDetails(osmType, osmId); - if (!element?.tags) return { place: buildOsmDetails({}, osmType, osmId) }; - return { place: buildOsmDetails(element.tags, osmType, osmId) }; + const [element, nominatim] = await Promise.all([ + fetchOverpassDetails(osmType, osmId), + lookupNominatim(osmType, osmId, lang), + ]); + const details = buildOsmDetails(element?.tags || {}, osmType, osmId); + return { + place: { + ...details, + name: nominatim?.name || element?.tags?.name || '', + address: nominatim?.address || '', + lat: nominatim?.lat ?? null, + lng: nominatim?.lng ?? null, + osm_id: placeId, + }, + }; } // Google details