296 lines
12 KiB
JavaScript
296 lines
12 KiB
JavaScript
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 (
|
|
<Autocomplete
|
|
id={id}
|
|
freeSolo
|
|
fullWidth={fullWidth}
|
|
disabled={disabled}
|
|
sx={sx}
|
|
options={options}
|
|
filterOptions={(x) => 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) => (
|
|
<TextField
|
|
{...params}
|
|
{...TextFieldProps}
|
|
inputRef={inputRef}
|
|
label={label}
|
|
placeholder={placeholder}
|
|
sx={textFieldSx}
|
|
autoComplete="off"
|
|
InputProps={{
|
|
...params.InputProps,
|
|
...TextFieldProps.InputProps,
|
|
startAdornment: TextFieldProps.InputProps?.startAdornment ?? params.InputProps.startAdornment,
|
|
endAdornment: (
|
|
<>
|
|
{loading ? <CircularProgress color="inherit" size={16} /> : null}
|
|
{TextFieldProps.InputProps?.endAdornment ?? params.InputProps.endAdornment}
|
|
</>
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default AddressAutocomplete;
|