From 18c158fb61be47ee44aa6e0b74992b08de7ed5a5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 19:06:22 +0000 Subject: [PATCH] changes --- apps/edr-freight-web/backoffice/package.json | 1 + apps/edr-freight-web/backoffice/src/main.tsx | 1 + .../new-booking-form/LocationPicker.tsx | 115 +++++++----------- pnpm-lock.yaml | 3 + 4 files changed, 51 insertions(+), 69 deletions(-) diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 48b9bc0a9..72c782e11 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -17,6 +17,7 @@ "@edr/ui-common": "workspace:*", "@hello-pangea/dnd": "^18.0.1", "@mantine/core": "^9.3.0", + "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index 3af3f0758..8c467156e 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; import "@mantine/core/styles.css"; +import "@mantine/dates/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; 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 3d3bc2d09..1b75ab65c 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 @@ -1,14 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Autocomplete, Box, Button, - Combobox, Group, - InputBase, Loader, Modal, Text, - useCombobox, } from "@mantine/core"; import { Check, MapPin, Search } from "lucide-react"; import { @@ -265,7 +263,7 @@ export interface LocationPickerProps { /** * Address + map location picker backed by Google Maps: - * - type to search (Geocoding API forward geocoding, debounced), + * - type to search (Places Autocomplete, debounced), * - or click anywhere on the map to drop a pin (reverse geocoding). * Reports the resolved address and coordinates up via `onChange`. */ @@ -386,7 +384,6 @@ function LocationPickerInline({ mapHeight = 260, withinPortal = true, }: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) { - const combobox = useCombobox(); const geocoder = useGeocoder(); const places = usePlacesSearch(); const placesLib = useMapsLibrary("places"); @@ -394,7 +391,6 @@ function LocationPickerInline({ 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. @@ -409,9 +405,8 @@ function LocationPickerInline({ // Debounced forward search — fires only after the user stops typing // (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather - // than one per keystroke. The dropdown is kept open the whole time so the - // user sees the "Searching…" state and then the live results for what they - // typed. + // than one per keystroke. While it runs, the input shows a spinner; the + // dropdown itself only appears once there are predictions to show. useEffect(() => { const q = query.trim(); if (q.length < MIN_QUERY_LEN) { @@ -420,12 +415,10 @@ function LocationPickerInline({ return; } setSearching(true); - combobox.openDropdown(); 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( places.autocomplete, @@ -435,13 +428,12 @@ function LocationPickerInline({ if (token.stale) return; setResults(found); setSearching(false); - combobox.openDropdown(); }, SEARCH_DEBOUNCE_MS); return () => { clearTimeout(handle); token.stale = true; }; - }, [query, places, combobox]); + }, [query, places]); // Drop any in-flight reverse lookup when the picker unmounts. useEffect( @@ -453,7 +445,10 @@ function LocationPickerInline({ const selectResult = useCallback( async (prediction: PlacePrediction) => { - combobox.closeDropdown(); + // Clear the query/results immediately so the pending debounce can't fire + // a search for the picked address and pop the dropdown back open. + setQuery(""); + setResults([]); // Predictions carry no coordinates — resolve them now via Place Details. if (!places) return; setResolving(true); @@ -474,10 +469,8 @@ function LocationPickerInline({ lat: resolved.lat, lng: resolved.lng, }); - setQuery(""); - setResults([]); }, - [onChange, combobox, places, placesLib], + [onChange, places, placesLib], ); const handlePin = useCallback( @@ -515,60 +508,44 @@ function LocationPickerInline({ ? { lat: value.lat as number, lng: value.lng as number } : DEFAULT_CENTER; + // Mantine Autocomplete requires unique option values; predictions are keyed + // by their display text, so de-duplicate the rare identical descriptions. + const optionsByName = useMemo(() => { + const byName = new Map(); + for (const r of results) { + if (!byName.has(r.displayName)) byName.set(r.displayName, r); + } + return byName; + }, [results]); + return ( - - - } - rightSection={searching || resolving ? : null} - onChange={(e) => { - setQuery(e.currentTarget.value); - combobox.openDropdown(); - }} - onFocus={() => { - if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown(); - }} - /> - - - - - {searching ? ( - Searching “{query.trim()}”… - ) : results.length === 0 ? ( - - {query.trim().length < MIN_QUERY_LEN - ? `Type at least ${MIN_QUERY_LEN} characters` - : "No matching places"} - - ) : ( - results.map((r, i) => ( - void selectResult(r)} - > - - {r.displayName} - - - )) - )} - - - + } + rightSection={searching || resolving ? : null} + data={[...optionsByName.keys()]} + // Predictions are already ranked by the Places API for the typed + // query; Mantine's default substring filter would hide most of them. + filter={({ options }) => options} + maxDropdownHeight={240} + comboboxProps={{ withinPortal, shadow: "md", radius: "md" }} + onChange={setQuery} + onOptionSubmit={(name) => { + const prediction = optionsByName.get(name); + if (prediction) void selectResult(prediction); + }} + renderOption={({ option }) => ( + + {option.value} + + )} + />