import React, { useEffect, useMemo, useState } from 'react'; import { Autocomplete, TextField, CircularProgress } from '@mui/material'; import { debounce } from '@mui/material/utils'; import { OpenLocationCode } from 'open-location-code'; // Free OSM Nominatim geocoder — replaces Google Places Autocomplete / // react-geocode across the console. No API key required. Nominatim's usage // policy caps the public endpoint at ~1 req/sec, so every caller here is // debounced (500ms) and cancels stale in-flight predictions. const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse'; // Builds a Google-Places-shaped `address_components` array (each entry // `{ long_name, short_name, types: [] }`) from Nominatim's flat `address` // object, so existing call sites' `place.address_components.forEach(...)` // type-switch parsing keeps working unchanged. const buildAddressComponents = (addr = {}) => { const components = []; const push = (longName, types) => { if (longName) components.push({ long_name: longName, short_name: longName, types }); }; push(addr.house_number, ['street_number']); push(addr.road || addr.pedestrian, ['route']); push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']); push(addr.city_district || addr.county, ['administrative_area_level_3']); push(addr.city || addr.town || addr.village, ['locality']); push(addr.state, ['administrative_area_level_1']); push(addr.country, ['country']); push(addr.postcode, ['postal_code']); return components; }; // Shapes a Nominatim result close enough to a Google Places `place` object // (formatted_address, geometry.location.lat()/lng(), address_components) // that call sites need only mechanical edits, not rewrites. export const toPlace = (result) => ({ formatted_address: result.display_name, name: result.display_name?.split(',')[0] || '', geometry: { location: { lat: () => parseFloat(result.lat), lng: () => parseFloat(result.lon) } }, address_components: buildAddressComponents(result.address) }); // Nominatim requests have no built-in timeout — a stalled connection (proxy // silently dropping packets, an overloaded public endpoint) would otherwise // leave `fetch` pending forever, so callers see the spinner spin with nothing // ever happening. This bounds every request to REQUEST_TIMEOUT_MS. const REQUEST_TIMEOUT_MS = 8000; const fetchWithTimeout = (url, options) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer)); }; // Nominatim (and Google Places, previously) both do full-text search against // a database of named places — neither understands Plus Codes (e.g. // "5CM7+8Q7"), because a Plus Code isn't a name to look up, it's lat/lng // encoded directly into the string. Decoding it is a pure local computation // via the Open Location Code algorithm, so this resolves reliably even where // OSM has zero data for the area (small towns, unlisted businesses). const olc = new OpenLocationCode(); const PLUS_CODE_CHARS = '23456789CFGHJMPQRVWX'; const PLUS_CODE_RE = new RegExp(`[${PLUS_CODE_CHARS}]{2,8}\\+[${PLUS_CODE_CHARS}]{2,3}`, 'i'); // A decoded Plus Code is just coordinates — on its own it has no street/ // locality name to show the operator, only the code itself. Reverse-geocoding // those coordinates fills in the human-readable side (road, suburb, city...) // the same way a normal search result would, so the option in the dropdown // reads as an actual address instead of a bare code. Best-effort: if Nominatim // has no data for that exact point either, callers fall back to the code text. async function reverseGeocode(lat, lon) { try { const params = new URLSearchParams({ lat: String(lat), lon: String(lon), format: 'json', addressdetails: '1' }); const res = await fetchWithTimeout(`${NOMINATIM_REVERSE_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }); if (!res.ok) return null; const result = await res.json(); if (!result || result.error) return null; return result; } catch (err) { return null; } } // Short codes (the common case — "5CM7+8Q7" rather than the full // "6JWV5CM7+8Q7") only encode an offset; recovering the actual location // needs a nearby reference point, which is what `bias` (the operator's // tenant/app location) provides. Returns a Nominatim-result-shaped object // (same shape `toPlace()` expects) so it can flow through the exact same // path as a real search hit, or null if no code is present / it can't be // resolved without a reference point. const plusCodeToResult = async (text, bias) => { const match = text?.match(PLUS_CODE_RE); if (!match) return null; const code = match[0].toUpperCase(); if (!olc.isValid(code)) return null; try { let fullCode = code; if (olc.isShort(code)) { const refLat = Number(bias?.lat); const refLng = Number(bias?.lng); // (0,0) isn't a plausible tenant location — it's what `Number('')` and // `Number(undefined)` half-produce (empty string coerces to 0), so treat // it as "no reference point yet" rather than decoding against Null Island. if (!Number.isFinite(refLat) || !Number.isFinite(refLng) || (refLat === 0 && refLng === 0)) return null; fullCode = olc.recoverNearest(code, refLat, refLng); } if (!olc.isFull(fullCode)) return null; const { latitudeCenter, longitudeCenter } = olc.decode(fullCode); // The decoded centroid is exact — keep it as the authoritative coordinate // even though `reverse` below carries its own (possibly snapped-to-nearest- // feature) lat/lon; only its display_name/address are what we want. const reverse = await reverseGeocode(latitudeCenter, longitudeCenter); return { ...reverse, place_id: `pluscode-${fullCode}`, lat: String(latitudeCenter), lon: String(longitudeCenter), display_name: reverse?.display_name ? `${reverse.display_name} (near ${code})` : `${code} (Plus Code location)`, address: reverse?.address || {} }; } catch (err) { return null; } }; const buildParams = (query, bias) => { const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' }); // bias.lat/lng arrive as strings from the tenant-location API. `-` coerces to // number automatically, but `+` on a string concatenates instead of adding // (e.g. "8.17445" + 0.5 === "8.174450.5"), producing a malformed, non-numeric // viewbox that Nominatim rejects with 400 Bad Request on every single search. const lat = Number(bias?.lat); const lng = Number(bias?.lng); if (Number.isFinite(lat) && Number.isFinite(lng)) { const d = 0.5; // ~55km box around the operator's zone — soft bias, not a hard filter params.set('viewbox', `${lng - d},${lat + d},${lng + d},${lat - d}`); } return params; }; // Standalone forward-geocode helper — replaces `Geocode.fromAddress(address)` // for call sites that resolve a plain text address without a predictions // dropdown (e.g. re-geocoding on submit). Returns a `toPlace`-shaped object, // or null if nothing matched. export async function geocodeAddress(address, { bias } = {}) { if (!address) return null; const plusCodeResult = await plusCodeToResult(address, bias); if (plusCodeResult) return toPlace(plusCodeResult); try { const params = buildParams(address, bias); params.set('limit', '1'); const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }); if (!res.ok) return null; const results = await res.json(); if (!results?.length) return null; return toPlace(results[0]); } catch (err) { console.error('geocodeAddress error:', err); return null; } } // Address search-as-you-type — replaces Google Places Autocomplete // (usePlacesWidget / window.google.maps.places.Autocomplete / // AutocompleteService) across the console. Renders as an MUI Autocomplete // with freeSolo so the operator can still submit free text. // // Props: // value / onChange(text) — controlled free-text input value // onPlaceSelected(place) — fired when a suggestion is chosen; `place` // is shaped like a Google Places `place` // bias { lat, lng } — optional soft bias toward the operator's zone const AddressAutocomplete = ({ id, label, placeholder = 'Search address', value, onChange, onPlaceSelected, bias, sx, textFieldSx, fullWidth = true, disabled, inputRef, TextFieldProps = {} }) => { const [inputValue, setInputValue] = useState(value || ''); const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (value !== undefined && value !== inputValue) setInputValue(value || ''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); const fetchPredictions = useMemo( () => debounce((query, bias1, callback) => { if (!query) { callback([]); return; } const params = buildParams(query, bias1); const searchPromise = fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }) .then((res) => (res.ok ? res.json() : [])) .catch(() => []); const plusCodePromise = plusCodeToResult(query, bias1).catch(() => null); Promise.all([searchPromise, plusCodePromise]).then(([results, plusCodeResult]) => { callback(plusCodeResult ? [plusCodeResult, ...(results || [])] : results || []); }); }, 500), [] ); useEffect(() => { let active = true; if (!inputValue) { setOptions([]); setLoading(false); return undefined; } setLoading(true); fetchPredictions(inputValue, bias, (results) => { if (!active) return; setLoading(false); setOptions(results); }); return () => { active = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputValue, bias?.lat, bias?.lng, fetchPredictions]); return ( x} getOptionLabel={(option) => (typeof option === 'string' ? option : option.display_name || '')} isOptionEqualToValue={(option, val) => option.place_id === val?.place_id} noOptionsText={ loading ? 'Searching…' : inputValue.trim().length >= 3 ? 'No match found — drop the business name/Plus Code, or try just City + Postcode' : 'Type an address to search' } inputValue={inputValue} onInputChange={(event, newInputValue) => { setInputValue(newInputValue); onChange?.(newInputValue); }} onChange={(event, selected) => { if (selected && typeof selected !== 'string') { setInputValue(selected.display_name || ''); onPlaceSelected?.(toPlace(selected)); } }} renderInput={(params) => ( {loading ? : null} {TextFieldProps.InputProps?.endAdornment ?? params.InputProps.endAdornment} ) }} /> )} /> ); }; export default AddressAutocomplete;