Files
TREK/server/src/routes/maps.ts
T
Ben Haas 35d676e76e 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
2026-04-09 15:19:49 -07:00

126 lines
4.2 KiB
TypeScript

import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import {
searchPlaces,
getPlaceDetails,
getPlacePhoto,
reverseGeocode,
resolveGoogleMapsUrl,
autocompletePlaces,
} from '../services/mapsService';
const router = express.Router();
// POST /search
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { query } = req.body;
if (!query) return res.status(400).json({ error: 'Search query is required' });
try {
const result = await searchPlaces(authReq.user.id, query, req.query.lang as string);
res.json(result);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 500;
const message = err instanceof Error ? err.message : 'Search error';
console.error('Maps search error:', err);
res.status(status).json({ error: message });
}
});
// 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;
const { placeId } = req.params;
try {
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
res.json(result);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 500;
const message = err instanceof Error ? err.message : 'Error fetching place details';
console.error('Maps details error:', err);
res.status(status).json({ error: message });
}
});
// GET /place-photo/:placeId
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { placeId } = req.params;
const lat = parseFloat(req.query.lat as string);
const lng = parseFloat(req.query.lng as string);
try {
const result = await getPlacePhoto(authReq.user.id, placeId, lat, lng, req.query.name as string);
res.json(result);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 500;
const message = err instanceof Error ? err.message : 'Error fetching photo';
if (status >= 500) console.error('Place photo error:', err);
res.status(status).json({ error: message });
}
});
// GET /reverse
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });
try {
const result = await reverseGeocode(lat, lng, lang);
res.json(result);
} catch {
res.json({ name: null, address: null });
}
});
// POST /resolve-url
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
const { url } = req.body;
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
try {
const result = await resolveGoogleMapsUrl(url);
res.json(result);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 400;
const message = err instanceof Error ? err.message : 'Failed to resolve URL';
console.error('[Maps] URL resolve error:', message);
res.status(status).json({ error: message });
}
});
export default router;