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 }> {