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). // first (Nominatim still returns global matches if nothing local fits).
const SEARCH_COUNTRYCODES = "et,dj"; const SEARCH_COUNTRYCODES = "et,dj";
/** Forward-geocode a free-text query to candidate places (free Nominatim API). */ /** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> { async function nominatimSearch(
query: string,
signal: AbortSignal,
countryCodes?: string,
): Promise<GeocodeResult[]> {
const params = new URLSearchParams({ const params = new URLSearchParams({
q: query, q: query,
format: "jsonv2", format: "jsonv2",
addressdetails: "0", addressdetails: "0",
limit: "8", limit: "8",
countrycodes: SEARCH_COUNTRYCODES,
}); });
if (countryCodes) params.set("countrycodes", countryCodes);
const res = await fetch(`${NOMINATIM_URL}?${params}`, { const res = await fetch(`${NOMINATIM_URL}?${params}`, {
signal, signal,
headers: { Accept: "application/json", "Accept-Language": "en" }, 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. */ /** Reverse-geocode a dropped pin to its nearest address. */
async function reverseGeocode(lat: number, lng: number): Promise<string> { async function reverseGeocode(lat: number, lng: number): Promise<string> {
const params = new URLSearchParams({ 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. */ /** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) { function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
const map = useMap(); const map = useMap();
@@ -158,22 +188,28 @@ export function LocationPicker({
} }
setSearching(true); setSearching(true);
combobox.openDropdown(); combobox.openDropdown();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const handle = setTimeout(async () => { const handle = setTimeout(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try { try {
const found = await searchPlaces(q, controller.signal); const found = await searchPlaces(q, controller.signal);
if (controller.signal.aborted) return;
setResults(found); setResults(found);
combobox.openDropdown(); combobox.openDropdown();
} catch (err) { } catch (err) {
// Ignore aborts (a newer keystroke superseded this request). // Ignore aborts (a newer keystroke superseded this request).
if ((err as Error)?.name !== "AbortError") setResults([]); if ((err as Error)?.name !== "AbortError") setResults([]);
} finally { } finally {
setSearching(false); if (!controller.signal.aborted) setSearching(false);
} }
}, SEARCH_DEBOUNCE_MS); }, 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]); }, [query, combobox]);
const selectResult = useCallback( const selectResult = useCallback(
@@ -277,6 +313,7 @@ export function LocationPicker({
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
<InvalidateSizeOnMount />
<ClickToPin onPick={handlePin} /> <ClickToPin onPick={handlePin} />
<MapRecenter lat={value.lat} lng={value.lng} /> <MapRecenter lat={value.lat} lng={value.lng} />
{hasPin && ( {hasPin && (

View File

@@ -334,6 +334,10 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"equipmentReturn", "equipmentReturn",
"customsClearingEnabled", "customsClearingEnabled",
"customsClearingAgent", "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"], 3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
4: [ 4: [
@@ -341,9 +345,6 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"destinationYard", "destinationYard",
"primaryRouteQuantity", "primaryRouteQuantity",
"extraRoutes", "extraRoutes",
// First/last-mile pickup & delivery locations are captured here on the map.
"firstMile",
"lastMile",
"isHazardous", "isHazardous",
"isRefrigerated", "isRefrigerated",
], ],

View File

@@ -124,7 +124,7 @@ export function Step2ServiceType({
onChange={(value) => { onChange={(value) => {
field.onChange(value); field.onChange(value);
if (!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( form.setValue(
"firstMile", "firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null }, { enabled: false, pickUpAddress: "", lat: null, lng: null },
@@ -187,7 +187,7 @@ export function Step2ServiceType({
onChange={(value) => { onChange={(value) => {
field.onChange(value); field.onChange(value);
if (!value) { if (!value) {
// Clear the captured delivery location (set on the Route step). // Toggling off clears the captured delivery location below.
form.setValue( form.setValue(
"lastMile", "lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null }, { enabled: false, deliveryAddress: "", lat: null, lng: null },