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