feat(bookings): refactor location handling for first/last mile and improve geocoding logic

This commit is contained in:
Marshal
2026-06-24 00:39:43 +00:00
parent 7ec06f2ff0
commit 3655e50f2e
3 changed files with 51 additions and 13 deletions

View File

@@ -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<GeocodeResult[]> {
/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
async function nominatimSearch(
query: string,
signal: AbortSignal,
countryCodes?: string,
): Promise<GeocodeResult[]> {
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<Geocode
}));
}
/**
* Forward-geocode a free-text query. We try the EDR corridor (ET/DJ) first so
* local addresses rank highest, then fall back to a global search when nothing
* local matches — so the field never looks "broken" for an out-of-region query.
*/
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
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<string> {
const params = new URLSearchParams({
@@ -95,6 +110,21 @@ async function reverseGeocode(lat: number, lng: number): Promise<string> {
}
}
/**
* 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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<InvalidateSizeOnMount />
<ClickToPin onPick={handlePin} />
<MapRecenter lat={value.lat} lng={value.lng} />
{hasPin && (

View File

@@ -334,6 +334,10 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"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<number, Array<Path<BookingFormValues>>> = {
"destinationYard",
"primaryRouteQuantity",
"extraRoutes",
// First/last-mile pickup & delivery locations are captured here on the map.
"firstMile",
"lastMile",
"isHazardous",
"isRefrigerated",
],

View File

@@ -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 },