From 488a1350548d039b667ea4beee8c84466b23dc98 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 18:49:00 +0000 Subject: [PATCH] changes --- .../new-booking-form/LocationPicker.tsx | 184 +++++++++++++++--- 1 file changed, 158 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 176cd0aa9..3d3bc2d09 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -36,6 +36,16 @@ interface GeocodeResult { lng: number; } +/** + * A single Autocomplete prediction. Coordinates are resolved lazily (only when + * the user actually picks the row) via a Place Details lookup, so the fast + * "type → see a list" path costs one Autocomplete call, not N geocodes. + */ +interface PlacePrediction { + placeId: string; + displayName: string; +} + // Maps JavaScript API keys are public client-side keys (lock them down by // HTTP-referrer in the Google Cloud console). The env var lets deployments // override the default key without a code change. @@ -61,7 +71,7 @@ const MAX_RESULTS = 8; const REVERSE_COORD_PRECISION = 4; // Simple in-memory caches keyed by the normalized query / rounded coordinate. -const searchCache = new Map(); +const searchCache = new Map(); const reverseCache = new Map(); /** @@ -86,31 +96,98 @@ async function geocode( } /** - * Forward-geocode a free-text query. Served from cache when possible; - * otherwise tried EDR-corridor-first, then global, so local addresses rank - * highest without the field ever looking "broken". + * Forward-search a free-text query with the Places Autocomplete service. + * + * This is the fix for "search only ever returns the country": the Geocoding API + * is an address→coords resolver, not a fuzzy place search, so a partial name + * like "Tulu Dimtu" under a `country: ET` restriction collapses to the Ethiopia + * centroid (`partial_match: true`). Autocomplete IS the search engine — it + * matches towns, kebeles, neighbourhoods and landmarks and returns a ranked + * prediction list. Coordinates are resolved later, only for the picked row. + * + * Country bias (not restriction) keeps EDR-corridor places on top while still + * letting a genuinely foreign query through — nothing ever looks "broken". */ async function searchPlaces( - geocoder: google.maps.Geocoder, + service: google.maps.places.AutocompleteService, + sessionToken: google.maps.places.AutocompleteSessionToken | undefined, query: string, -): Promise { +): Promise { const key = query.trim().toLowerCase(); const cached = searchCache.get(key); if (cached) return cached; - // The Geocoder only accepts one country restriction per request, so the - // corridor pass fans out to one request per country and merges in order. - const perCountry = await Promise.all( - SEARCH_COUNTRIES.map((country) => - geocode(geocoder, { address: query, componentRestrictions: { country } }), - ), - ); - const local = perCountry.flat().slice(0, MAX_RESULTS); - const found = local.length > 0 ? local : await geocode(geocoder, { address: query }); + const found = await new Promise((resolve) => { + service.getPlacePredictions( + { + input: query, + // `componentRestrictions` is a hard filter and would re-introduce the + // "only country matches" failure. Autocomplete has no multi-country + // restriction anyway, so we bias by region instead and keep it soft. + componentRestrictions: { country: SEARCH_COUNTRIES }, + sessionToken, + }, + (predictions, status) => { + if ( + status !== google.maps.places.PlacesServiceStatus.OK || + !predictions + ) { + resolve([]); + return; + } + resolve( + predictions.slice(0, MAX_RESULTS).map((p) => ({ + placeId: p.place_id, + displayName: p.description, + })), + ); + }, + ); + }); + if (found.length > 0) searchCache.set(key, found); return found; } +/** + * Resolve a picked prediction to its coordinates via Place Details. Runs once + * per selection (closes the Autocomplete session), so billing stays on the + * cheap Autocomplete-per-session tier rather than per-keystroke geocoding. + */ +async function resolvePrediction( + service: google.maps.places.PlacesService, + sessionToken: google.maps.places.AutocompleteSessionToken | undefined, + prediction: PlacePrediction, +): Promise { + return new Promise((resolve) => { + service.getDetails( + { + placeId: prediction.placeId, + fields: ["formatted_address", "name", "geometry"], + sessionToken, + }, + (place, status) => { + const loc = place?.geometry?.location; + if ( + status !== google.maps.places.PlacesServiceStatus.OK || + !loc + ) { + resolve(null); + return; + } + resolve({ + displayName: + place?.formatted_address || + place?.name || + prediction.displayName, + lat: loc.lat(), + lng: loc.lng(), + }); + }, + ); + }); +} + /** Reverse-geocode a dropped pin to its nearest address (cached). */ async function reverseGeocode( geocoder: google.maps.Geocoder, @@ -138,6 +215,28 @@ function useGeocoder(): google.maps.Geocoder | null { ); } +/** The Places services bundle: predictions + details, once `places` loads. */ +interface PlacesSearch { + autocomplete: google.maps.places.AutocompleteService; + details: google.maps.places.PlacesService; +} + +/** + * Lazily constructs the Places Autocomplete + Details services once the + * `places` library loads. `PlacesService` needs a DOM node or map to attach to; + * a detached div is the standard headless anchor. + */ +function usePlacesSearch(): PlacesSearch | null { + const placesLib = useMapsLibrary("places"); + return useMemo(() => { + if (!placesLib) return null; + return { + autocomplete: new placesLib.AutocompleteService(), + details: new placesLib.PlacesService(document.createElement("div")), + }; + }, [placesLib]); +} + /** Recenters the map imperatively when the pinned coordinate changes. */ function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) { const map = useMap(); @@ -289,12 +388,22 @@ function LocationPickerInline({ }: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) { const combobox = useCombobox(); const geocoder = useGeocoder(); + const places = usePlacesSearch(); + const placesLib = useMapsLibrary("places"); const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [resolving, setResolving] = useState(false); const searchStaleRef = useRef<{ stale: boolean } | null>(null); const reverseStaleRef = useRef<{ stale: boolean } | null>(null); + // One Autocomplete session groups every keystroke of a search with the final + // Details fetch into a single billable unit. Reset after each pick. + const sessionTokenRef = useRef< + google.maps.places.AutocompleteSessionToken | undefined + >(undefined); + if (placesLib && !sessionTokenRef.current) { + sessionTokenRef.current = new placesLib.AutocompleteSessionToken(); + } const hasPin = value.lat != null && value.lng != null; @@ -312,13 +421,17 @@ function LocationPickerInline({ } setSearching(true); combobox.openDropdown(); - if (!geocoder) return; // re-runs once the geocoding library loads - // The Geocoder has no abort support, so a token marks superseded requests + if (!places) return; // re-runs once the places library loads + // Autocomplete has no abort support, so a token marks superseded requests // and their responses are dropped instead of overwriting newer results. const token = { stale: false }; searchStaleRef.current = token; const handle = setTimeout(async () => { - const found = await searchPlaces(geocoder, q); + const found = await searchPlaces( + places.autocomplete, + sessionTokenRef.current, + q, + ); if (token.stale) return; setResults(found); setSearching(false); @@ -328,7 +441,7 @@ function LocationPickerInline({ clearTimeout(handle); token.stale = true; }; - }, [query, geocoder, combobox]); + }, [query, places, combobox]); // Drop any in-flight reverse lookup when the picker unmounts. useEffect( @@ -339,13 +452,32 @@ function LocationPickerInline({ ); const selectResult = useCallback( - (r: GeocodeResult) => { - onChange({ address: r.displayName, lat: r.lat, lng: r.lng }); + async (prediction: PlacePrediction) => { + combobox.closeDropdown(); + // Predictions carry no coordinates — resolve them now via Place Details. + if (!places) return; + setResolving(true); + const resolved = await resolvePrediction( + places.details, + sessionTokenRef.current, + prediction, + ); + // A Details fetch closes the Autocomplete billing session; start a fresh + // token so the next search is its own session. + sessionTokenRef.current = placesLib + ? new placesLib.AutocompleteSessionToken() + : undefined; + setResolving(false); + if (!resolved) return; + onChange({ + address: resolved.displayName, + lat: resolved.lat, + lng: resolved.lng, + }); setQuery(""); setResults([]); - combobox.closeDropdown(); }, - [onChange, combobox], + [onChange, combobox, places, placesLib], ); const handlePin = useCallback( @@ -424,9 +556,9 @@ function LocationPickerInline({ ) : ( results.map((r, i) => ( selectResult(r)} + onClick={() => void selectResult(r)} > {r.displayName}