Skip to content

Commit ddcf720

Browse files
committed
Implement new Places Autocomplete Element support in LocationMapPicker; add handling for place selection and update internal position accordingly. Enhance location bias and restrictions for improved search results.
1 parent 85c9a78 commit ddcf720

File tree

1 file changed

+224
-9
lines changed

1 file changed

+224
-9
lines changed

src/frontend/src/components/common/LocationMapPicker.tsx

Lines changed: 224 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ const LocationMapPicker: React.FC<LocationMapPickerProps> = ({
4444
const mapRef = useRef<google.maps.Map | null>(null); // Use a ref for the map instance
4545
const autocompleteRef = useRef<google.maps.places.Autocomplete | null>(null);
4646
const placeListenerRef = useRef<google.maps.MapsEventListener | null>(null);
47+
// New Autocomplete Element (2025) support
48+
const placeElRef = useRef<any>(null);
49+
const placeElContainerRef = useRef<HTMLDivElement | null>(null);
50+
const placeElHandlerRef = useRef<((e: any) => void) | null>(null);
51+
const placesServiceRef = useRef<google.maps.places.PlacesService | null>(
52+
null,
53+
);
54+
const [useNewAutocomplete, setUseNewAutocomplete] = useState<boolean>(false);
4755
const geocoderRef = useRef<google.maps.Geocoder | null>(null);
4856
const inputRef = useRef<HTMLInputElement | null>(null);
4957

@@ -225,13 +233,152 @@ const LocationMapPicker: React.FC<LocationMapPickerProps> = ({
225233
}
226234
};
227235

228-
// Initialize native Places Autocomplete on the input (no @react-google-maps/api wrapper)
236+
// Handler for new PlaceAutocompleteElement selections
237+
const onPlaceElementChanged = useCallback(() => {
238+
const g: any = (window as any).google;
239+
const el = placeElRef.current;
240+
if (!g?.maps?.places || !el) return;
241+
// Try to get place directly; fallback to value.place/placeId
242+
let place: any = null;
243+
try {
244+
if (typeof el.getPlace === "function") {
245+
place = el.getPlace();
246+
}
247+
if (!place && el.value) {
248+
place = el.value?.place || el.value;
249+
}
250+
} catch {}
251+
252+
const processPlace = (p: any) => {
253+
if (!p || !p.geometry || !p.geometry.location) return;
254+
const rawName = p.name || "";
255+
const getAddressComponent = (type: string): string => {
256+
if (!p.address_components) return "";
257+
const comp = p.address_components.find((c: any) =>
258+
c.types.includes(type),
259+
);
260+
return comp ? comp.long_name : "";
261+
};
262+
const route = getAddressComponent("route");
263+
const barangay =
264+
getAddressComponent("sublocality_level_1") ||
265+
getAddressComponent("sublocality") ||
266+
getAddressComponent("neighborhood");
267+
const city =
268+
getAddressComponent("locality") ||
269+
getAddressComponent("administrative_area_level_2");
270+
const province = getAddressComponent("administrative_area_level_1");
271+
272+
let displayAddress = composeFormattedWithPlace(
273+
rawName,
274+
p.formatted_address,
275+
);
276+
if (!displayAddress) {
277+
displayAddress = `${p.geometry.location.lat().toFixed(5)}, ${p.geometry.location
278+
.lng()
279+
.toFixed(5)}`;
280+
}
281+
const structured: StructuredLocation = {
282+
lat: p.geometry.location.lat(),
283+
lng: p.geometry.location.lng(),
284+
address: displayAddress,
285+
rawName: rawName || undefined,
286+
route: route || undefined,
287+
barangay: barangay || undefined,
288+
city: city || undefined,
289+
province: province || undefined,
290+
};
291+
setInternalPosition({ lat: structured.lat, lng: structured.lng });
292+
if (inputRef.current && !useNewAutocomplete)
293+
inputRef.current.value = structured.address;
294+
onChange(structured);
295+
persistLocation(structured);
296+
mapRef.current?.panTo({ lat: structured.lat, lng: structured.lng });
297+
mapRef.current?.setZoom(17);
298+
};
299+
300+
if (place && place.geometry && place.geometry.location) {
301+
processPlace(place);
302+
return;
303+
}
304+
// If we only have a place_id (common with prediction), fetch details
305+
const placeId =
306+
place?.place_id || el.placeId || el.value?.placeId || el.value?.place_id;
307+
if (placeId) {
308+
try {
309+
if (!placesServiceRef.current) {
310+
placesServiceRef.current = new g.maps.places.PlacesService(
311+
document.createElement("div"),
312+
);
313+
}
314+
if (!placesServiceRef.current) return;
315+
placesServiceRef.current.getDetails(
316+
{
317+
placeId,
318+
fields: [
319+
"geometry",
320+
"name",
321+
"formatted_address",
322+
"address_components",
323+
],
324+
},
325+
(res: any, status: any) => {
326+
if (status === g.maps.places.PlacesServiceStatus.OK && res) {
327+
processPlace(res);
328+
}
329+
},
330+
);
331+
} catch {}
332+
}
333+
}, [onChange, persistLocation, useNewAutocomplete]);
334+
335+
// Initialize Places Autocomplete, preferring the new PlaceAutocompleteElement when available
229336
useEffect(() => {
230337
let intervalId: number | null = null;
231338
const init = () => {
232-
if (autocompleteRef.current || !inputRef.current) return false;
233339
const g = (window as any).google;
234340
if (!g?.maps?.places) return false;
341+
// Try new element first
342+
try {
343+
if (
344+
g.maps.places.PlaceAutocompleteElement &&
345+
placeElContainerRef.current
346+
) {
347+
// Create and attach the new element
348+
const el = new g.maps.places.PlaceAutocompleteElement();
349+
placeElRef.current = el;
350+
// Optional: bias types to geocode-like results
351+
try {
352+
el.types = ["geocode"]; // best-effort; ignored if not supported
353+
} catch {}
354+
// Region restriction (Philippines) and initial bias around current position
355+
try {
356+
// New element uses 'countries' for restriction
357+
(el as any).countries = ["ph"]; // ISO 3166-1 alpha-2
358+
} catch {}
359+
try {
360+
// Bias searches around the current internalPosition (~80km radius)
361+
(el as any).locationBias = {
362+
center: { lat: internalPosition.lat, lng: internalPosition.lng },
363+
radius: 5500,
364+
} as any;
365+
} catch {}
366+
// Wire selection listener
367+
const handler = () => onPlaceElementChanged();
368+
placeElHandlerRef.current = handler;
369+
// Some builds dispatch 'place_changed', others 'gmpxplacechanged'; listen to both
370+
el.addEventListener?.("place_changed", handler);
371+
el.addEventListener?.("gmpxplacechanged", handler);
372+
// Mount element into container
373+
placeElContainerRef.current.innerHTML = "";
374+
placeElContainerRef.current.appendChild(el);
375+
setUseNewAutocomplete(true);
376+
return true;
377+
}
378+
} catch {}
379+
380+
// Fallback: legacy Autocomplete bound to our input
381+
if (autocompleteRef.current || !inputRef.current) return false;
235382
try {
236383
autocompleteRef.current = new g.maps.places.Autocomplete(
237384
inputRef.current,
@@ -243,6 +390,7 @@ const LocationMapPicker: React.FC<LocationMapPickerProps> = ({
243390
"address_components",
244391
],
245392
types: ["geocode"],
393+
componentRestrictions: { country: ["ph"] },
246394
// You can add componentRestrictions here if needed
247395
},
248396
);
@@ -270,12 +418,67 @@ const LocationMapPicker: React.FC<LocationMapPickerProps> = ({
270418
}, 200);
271419
}
272420
return () => {
421+
// Cleanup legacy autocomplete listener
273422
if (placeListenerRef.current) {
274423
placeListenerRef.current.remove();
275424
placeListenerRef.current = null;
276425
}
426+
// Cleanup new element listeners and DOM
427+
if (placeElRef.current && placeElHandlerRef.current) {
428+
try {
429+
placeElRef.current.removeEventListener?.(
430+
"place_changed",
431+
placeElHandlerRef.current,
432+
);
433+
placeElRef.current.removeEventListener?.(
434+
"gmpxplacechanged",
435+
placeElHandlerRef.current,
436+
);
437+
} catch {}
438+
}
439+
if (placeElContainerRef.current) {
440+
try {
441+
placeElContainerRef.current.innerHTML = "";
442+
} catch {}
443+
}
277444
};
278-
}, [onPlaceChanged]);
445+
}, [onPlaceChanged, onPlaceElementChanged]);
446+
447+
// Update bias/restrictions when the internal position changes
448+
useEffect(() => {
449+
const g: any = (window as any).google;
450+
if (!g?.maps?.places) return;
451+
try {
452+
if (useNewAutocomplete && placeElRef.current) {
453+
// Bias around current internalPosition
454+
(placeElRef.current as any).locationBias = {
455+
center: { lat: internalPosition.lat, lng: internalPosition.lng },
456+
radius: 80000,
457+
} as any;
458+
// Ensure country restriction remains applied
459+
try {
460+
(placeElRef.current as any).countries = ["ph"];
461+
} catch {}
462+
} else if (autocompleteRef.current) {
463+
// Legacy: bias via bounds (not strict)
464+
const delta = 0.4; // ~44km latitude; longitude varies with lat
465+
const sw = new g.maps.LatLng(
466+
internalPosition.lat - delta,
467+
internalPosition.lng - delta,
468+
);
469+
const ne = new g.maps.LatLng(
470+
internalPosition.lat + delta,
471+
internalPosition.lng + delta,
472+
);
473+
const bounds = new g.maps.LatLngBounds(sw, ne);
474+
autocompleteRef.current.setBounds(bounds);
475+
autocompleteRef.current.setOptions({
476+
strictBounds: false,
477+
componentRestrictions: { country: ["ph"] },
478+
} as any);
479+
}
480+
} catch {}
481+
}, [internalPosition, useNewAutocomplete]);
279482

280483
// Load persisted location if available and no value provided
281484
React.useEffect(() => {
@@ -296,12 +499,24 @@ const LocationMapPicker: React.FC<LocationMapPickerProps> = ({
296499
return (
297500
<div className="space-y-2">
298501
<label className="text-xs font-medium text-gray-600">{label}</label>
299-
<input
300-
ref={inputRef}
301-
type="text"
302-
placeholder="Search location or drag the pin"
303-
className={`w-full rounded-lg border p-2 text-sm focus:border-blue-500 focus:outline-none ${highlight ? "border-red-500 ring-2 ring-red-200" : "border-gray-300"}`}
304-
/>
502+
{useNewAutocomplete ? (
503+
<div
504+
ref={placeElContainerRef}
505+
className={`w-full rounded-lg border p-2 text-sm ${
506+
highlight ? "border-red-500 ring-2 ring-red-200" : "border-gray-300"
507+
}`}
508+
// The new element will be injected here
509+
/>
510+
) : (
511+
<input
512+
ref={inputRef}
513+
type="text"
514+
placeholder="Search location or drag the pin"
515+
className={`w-full rounded-lg border p-2 text-sm focus:border-blue-500 focus:outline-none ${
516+
highlight ? "border-red-500 ring-2 ring-red-200" : "border-gray-300"
517+
}`}
518+
/>
519+
)}
305520
<div
306521
className={`rounded-xl ${highlight ? "border-2 border-red-500 ring-2 ring-red-200" : "border border-gray-200"}`}
307522
style={{ overflow: "hidden" }}

0 commit comments

Comments
 (0)