From 3655e50f2e7fdc6771518f514e656a6fb71eaf13 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 00:39:43 +0000 Subject: [PATCH] feat(bookings): refactor location handling for first/last mile and improve geocoding logic --- .../new-booking-form/LocationPicker.tsx | 53 ++++++++++++++++--- .../pages/bookings/new-booking-form/schema.ts | 7 +-- .../new-booking-form/step2-service-type.tsx | 4 +- 3 files changed, 51 insertions(+), 13 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 5835b34a4..e9ae9f75f 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 @@ -50,15 +50,19 @@ const MIN_QUERY_LEN = 2; // first (Nominatim still returns global matches if nothing local fits). const SEARCH_COUNTRYCODES = "et,dj"; -/** Forward-geocode a free-text query to candidate places (free Nominatim API). */ -async function searchPlaces(query: string, signal: AbortSignal): Promise { +/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */ +async function nominatimSearch( + query: string, + signal: AbortSignal, + countryCodes?: string, +): Promise { const params = new URLSearchParams({ q: query, format: "jsonv2", addressdetails: "0", limit: "8", - countrycodes: SEARCH_COUNTRYCODES, }); + if (countryCodes) params.set("countrycodes", countryCodes); const res = await fetch(`${NOMINATIM_URL}?${params}`, { signal, headers: { Accept: "application/json", "Accept-Language": "en" }, @@ -76,6 +80,17 @@ async function searchPlaces(query: string, signal: AbortSignal): Promise { + const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES); + if (local.length > 0) return local; + return nominatimSearch(query, signal); +} + /** Reverse-geocode a dropped pin to its nearest address. */ async function reverseGeocode(lat: number, lng: number): Promise { const params = new URLSearchParams({ @@ -95,6 +110,21 @@ async function reverseGeocode(lat: number, lng: number): Promise { } } +/** + * Leaflet computes its tile layout from the container size at mount. When the + * map is revealed inside a just-toggled section it can mount before layout + * settles and render grey tiles — invalidating the size on the next frame + * forces a correct redraw. + */ +function InvalidateSizeOnMount() { + const map = useMap(); + useEffect(() => { + const id = setTimeout(() => map.invalidateSize(), 0); + return () => clearTimeout(id); + }, [map]); + return null; +} + /** Recenters the map imperatively when the pinned coordinate changes. */ function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) { const map = useMap(); @@ -158,22 +188,28 @@ export function LocationPicker({ } setSearching(true); combobox.openDropdown(); + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; const handle = setTimeout(async () => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; try { const found = await searchPlaces(q, controller.signal); + if (controller.signal.aborted) return; setResults(found); combobox.openDropdown(); } catch (err) { // Ignore aborts (a newer keystroke superseded this request). if ((err as Error)?.name !== "AbortError") setResults([]); } finally { - setSearching(false); + if (!controller.signal.aborted) setSearching(false); } }, SEARCH_DEBOUNCE_MS); - return () => clearTimeout(handle); + // Cancel both the pending debounce AND any in-flight request when the query + // changes, so a stale response can't overwrite newer results. + return () => { + clearTimeout(handle); + controller.abort(); + }; }, [query, combobox]); const selectResult = useCallback( @@ -277,6 +313,7 @@ export function LocationPicker({ attribution='© OpenStreetMap contributors' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> + {hasPin && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 4f6a1647b..5bac52a36 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -334,6 +334,10 @@ export const stepFields: Record>> = { "equipmentReturn", "customsClearingEnabled", "customsClearingAgent", + // First/last-mile pickup & delivery locations are captured inline in the + // service step, right under each trucking toggle. + "firstMile", + "lastMile", ], 3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"], 4: [ @@ -341,9 +345,6 @@ export const stepFields: Record>> = { "destinationYard", "primaryRouteQuantity", "extraRoutes", - // First/last-mile pickup & delivery locations are captured here on the map. - "firstMile", - "lastMile", "isHazardous", "isRefrigerated", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 1f5b92532..e326f84e7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -124,7 +124,7 @@ export function Step2ServiceType({ onChange={(value) => { field.onChange(value); if (!value) { - // Clear the captured pick-up location (set on the Route step). + // Toggling off clears the captured pick-up location below. form.setValue( "firstMile", { enabled: false, pickUpAddress: "", lat: null, lng: null }, @@ -187,7 +187,7 @@ export function Step2ServiceType({ onChange={(value) => { field.onChange(value); if (!value) { - // Clear the captured delivery location (set on the Route step). + // Toggling off clears the captured delivery location below. form.setValue( "lastMile", { enabled: false, deliveryAddress: "", lat: null, lng: null },