mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
feat(bookings): refactor location handling for first/last mile and improve geocoding logic
This commit is contained in:
@@ -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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <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 && (
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user