diff --git a/package-lock.json b/package-lock.json index a18dffc..f19d89c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "lodash": "^4.17.21", "mui-daterange-picker": "^1.0.5", "notistack": "^3.0.1", + "open-location-code": "^1.0.3", "papaparse": "^5.5.3", "process": "^0.11.10", "prop-types": "^15.8.1", @@ -14285,6 +14286,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-location-code": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/open-location-code/-/open-location-code-1.0.3.tgz", + "integrity": "sha512-DBm14BSn40Ee241n80zIFXIT6+y8Tb0I+jTdosLJ8Sidvr2qONvymwqymVbHV2nS+1gkDZ5eTNpnOIVV0Kn2fw==", + "license": "Apache-2.0" + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -30691,6 +30698,11 @@ "is-wsl": "^2.2.0" } }, + "open-location-code": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/open-location-code/-/open-location-code-1.0.3.tgz", + "integrity": "sha512-DBm14BSn40Ee241n80zIFXIT6+y8Tb0I+jTdosLJ8Sidvr2qONvymwqymVbHV2nS+1gkDZ5eTNpnOIVV0Kn2fw==" + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", diff --git a/package.json b/package.json index fc73379..b5cd241 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "lodash": "^4.17.21", "mui-daterange-picker": "^1.0.5", "notistack": "^3.0.1", + "open-location-code": "^1.0.3", "papaparse": "^5.5.3", "process": "^0.11.10", "prop-types": "^15.8.1", diff --git a/src/components/nearle_components/AddressAutocomplete.js b/src/components/nearle_components/AddressAutocomplete.js index 3212de9..fa9d9f3 100644 --- a/src/components/nearle_components/AddressAutocomplete.js +++ b/src/components/nearle_components/AddressAutocomplete.js @@ -2,12 +2,14 @@ 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` @@ -44,11 +46,99 @@ export const toPlace = (result) => ({ 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' }); - if (bias?.lat && bias?.lng) { + // 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', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); + params.set('viewbox', `${lng - d},${lat + d},${lng + d},${lat - d}`); } return params; }; @@ -59,10 +149,12 @@ const buildParams = (query, bias) => { // 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 fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }); + 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; @@ -115,10 +207,13 @@ const AddressAutocomplete = ({ return; } const params = buildParams(query, bias1); - fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }) + const searchPromise = fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } }) .then((res) => (res.ok ? res.json() : [])) - .then((results) => callback(results || [])) - .catch(() => callback([])); + .catch(() => []); + const plusCodePromise = plusCodeToResult(query, bias1).catch(() => null); + Promise.all([searchPromise, plusCodePromise]).then(([results, plusCodeResult]) => { + callback(plusCodeResult ? [plusCodeResult, ...(results || [])] : results || []); + }); }, 500), [] ); @@ -153,6 +248,13 @@ const AddressAutocomplete = ({ 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); diff --git a/src/pages/nearle/clients/createCustomer.js b/src/pages/nearle/clients/createCustomer.js index 2ebe20a..a4f39d3 100644 --- a/src/pages/nearle/clients/createCustomer.js +++ b/src/pages/nearle/clients/createCustomer.js @@ -25,7 +25,7 @@ import MainCard from 'components/MainCard'; import axios from 'axios'; -import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; +import AddressAutocomplete, { geocodeAddress } from 'components/nearle_components/AddressAutocomplete'; import Loader from 'components/Loader'; import { enqueueSnackbar } from 'notistack'; import { useNavigate } from 'react-router'; @@ -140,6 +140,30 @@ const CreateCustomer = () => { longitude: place.geometry.location.lng() }); }; + // ============================================= || Fallback geocode for manually typed address || ============================================= + // AddressAutocomplete only resolves lat/lng when a suggestion is actually clicked + // from its dropdown (handlePickPlaceSelected). The common real-world case is an + // operator typing/pasting the full address into that single Address box and never + // clicking a suggestion — that left startPoint at (0,0) forever, and because the + // submit validation below used to check pickCust.latitude for an empty string + // that's never actually set, customers were saved with latitude/longitude "0"/"0" + // with no warning at all. This covers both the raw Address-box text and the + // manually filled Location/City/Postcode fields as a fallback, reusing the same + // handler a real dropdown selection would call so the rest of the fields fill in too. + useEffect(() => { + if (startPoint.latitude !== 0 || startPoint.longitude !== 0) return undefined; + const manualFields = [doorno, pickCust.suburb, pickCust.city, pickCust.state, pickCust.postcode].filter(Boolean).join(', '); + const manualAddress = + (inputValue2 && inputValue2.trim().length >= 6 && inputValue2) || (pickCust.suburb && pickCust.city && manualFields); + if (!manualAddress) return undefined; + const timer = setTimeout(async () => { + const place = await geocodeAddress(manualAddress, { bias: { lat: appLocaLat, lng: appLocaLng } }); + if (place) handlePickPlaceSelected(place); + }, 800); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue2, doorno, pickCust.suburb, pickCust.city, pickCust.state, pickCust.postcode, appLocaLat, appLocaLng]); + // ==================================================== || getapplocations || ==================================================== const getapplocations = async () => { setLoading(true); @@ -554,10 +578,8 @@ const CreateCustomer = () => { opentoast('Enter Post Code '); } else if (landmark === '') { opentoast('Enter Land Mark '); - } else if (pickCust.latitude === '') { - opentoast('Invalid latitude '); - } else if (pickCust.longitude === '') { - opentoast('Invaiid Longitude '); + } else if (!startPoint.latitude || !startPoint.longitude) { + opentoast('Could not locate address on map, please recheck the address'); } else { createprofile(); } diff --git a/src/pages/nearle/orders/createorder1.js b/src/pages/nearle/orders/createorder1.js index c84930c..0dfe24e 100644 --- a/src/pages/nearle/orders/createorder1.js +++ b/src/pages/nearle/orders/createorder1.js @@ -46,7 +46,7 @@ import { TbMapPinCode } from 'react-icons/tb'; import { FaLocationDot } from 'react-icons/fa6'; import axios from 'axios'; import { useTheme } from '@mui/material/styles'; -import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; +import AddressAutocomplete, { geocodeAddress } from 'components/nearle_components/AddressAutocomplete'; import Loader from 'components/Loader'; import * as geolib from 'geolib'; import MainCard from 'components/MainCard'; @@ -578,10 +578,10 @@ const Createorder1 = () => { deliverycustomer: dropCust.firstname || '', deliveryid: isNumChange2 == 0 ? +dropCust.customerid || 0 : 0, deliverylandmark: dropCust.landmark || '', - deliverylat: dropCust.latitude.toString(), + deliverylat: (dropCust.latitude || 0).toString(), deliverylocation: dropCust.suburb || '', deliverylocationid: dropCust.deliverylocationid || 0, - deliverylong: dropCust.longitude.toString(), + deliverylong: (dropCust.longitude || 0).toString(), deliverytime: `${dayjs(startdate).format('YYYY-MM-DD')} ${dayjs(selectedtime.$d).format('HH:mm:ss')}`, deliverytype: pickCust.customerid !== 0 || dropCust.customerid !== 0 ? 'B' : 'C', delivered: '', @@ -606,10 +606,10 @@ const Createorder1 = () => { pickupcontactno: pickCust.contactno || '', pickupcustomer: pickCust.firstname || '', pickuplandmark: pickCust.landmark || '', - pickuplat: pickCust.latitude.toString(), + pickuplat: (pickCust.latitude || 0).toString(), pickuplocation: pickCust.suburb || '', pickuplocationid: pickCust.deliverylocationid || 0, - pickuplong: pickCust.longitude.toString(), + pickuplong: (pickCust.longitude || 0).toString(), processing: '', ready: '', remarks: '', @@ -637,8 +637,8 @@ const Createorder1 = () => { email: pickCust.email || '', firstname: pickCust.firstname || '', landmark: pickCust.landmark || '', - latitude: pickCust.latitude.toString() || '', - longitude: pickCust.longitude.toString() || '', + latitude: (pickCust.latitude || 0).toString(), + longitude: (pickCust.longitude || 0).toString(), locationid: pickCust.deliverylocationid || 0, postcode: pickCust.postcode || '', primaryaddress: 1, @@ -663,8 +663,8 @@ const Createorder1 = () => { email: dropCust.email || '', firstname: dropCust.firstname || '', landmark: dropCust.landmark || '', - latitude: dropCust.latitude.toString(), - longitude: dropCust.longitude.toString(), + latitude: (dropCust.latitude || 0).toString(), + longitude: (dropCust.longitude || 0).toString(), locationid: dropCust.deliverylocationid || 0, postcode: dropCust.postcode || '', primaryaddress: 1, @@ -689,6 +689,8 @@ const Createorder1 = () => { opentoast('Enter Pickup Postcode ', 'warning', 2000); } else if (!pickCust.landmark) { opentoast('Enter Pickup Landmark ', 'warning', 2000); + } else if (!pickCust.latitude || !pickCust.longitude) { + opentoast('Could not locate Pickup address on map, please recheck the address', 'error', 3000); } else if (!dropCust.firstname) { opentoast('Enter Drop Contact Name ', 'warning', 2000); } else if (!dropCust.contactno) { @@ -703,6 +705,8 @@ const Createorder1 = () => { opentoast('Enter Drop postcode ', 'warning', 2000); } else if (!dropCust.landmark) { opentoast('Enter Drop Landmark ', 'warning', 2000); + } else if (!dropCust.latitude || !dropCust.longitude) { + opentoast('Could not locate Drop address on map, please recheck the address', 'error', 3000); } else if (!selectedtime) { opentoast('Choose deliverytime ', 'warning', 2000); } else if (!setSubCatId) { @@ -991,6 +995,45 @@ const Createorder1 = () => { }); }; + // ============================================= || Fallback geocode for manually typed addresses || ============================================= + // AddressAutocomplete only resolves lat/lng when a suggestion is actually clicked + // from its dropdown (handlePickPlaceSelected/handleDropPlaceSelected). The common + // real-world case is an operator typing/pasting the full address into that single + // Address box and never clicking a suggestion (none showed up, or they just hit + // tab/moved on) — that left startPoint/endPoint at (0,0) forever, distance never + // calculated, and "Select Pickup and Drop" firing no matter what was typed. + // These effects cover that: once typing pauses, whatever address text exists — + // the raw Address-box text, or the manually filled Door No/Location/City/Postcode + // fields as a fallback — gets geocoded, reusing the same handler that a real + // dropdown selection would call so suburb/city/postcode get filled in too. + useEffect(() => { + if (startPoint.latitude !== 0 || startPoint.longitude !== 0) return undefined; + const manualAddress = + (inputValue2 && inputValue2.trim().length >= 6 && inputValue2) || + (pickCust.suburb && pickCust.city && [pickCust.doorno, pickCust.suburb, pickCust.city, pickCust.postcode].filter(Boolean).join(', ')); + if (!manualAddress) return undefined; + const timer = setTimeout(async () => { + const place = await geocodeAddress(manualAddress, { bias: { lat: appLocaLat, lng: appLocaLng } }); + if (place) handlePickPlaceSelected(place); + }, 800); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue2, pickCust.doorno, pickCust.suburb, pickCust.city, pickCust.postcode, appLocaLat, appLocaLng]); + + useEffect(() => { + if (endPoint.latitude !== 0 || endPoint.longitude !== 0) return undefined; + const manualAddress = + (inputValue3 && inputValue3.trim().length >= 6 && inputValue3) || + (dropCust.suburb && dropCust.city && [dropCust.doorno, dropCust.suburb, dropCust.city, dropCust.postcode].filter(Boolean).join(', ')); + if (!manualAddress) return undefined; + const timer = setTimeout(async () => { + const place = await geocodeAddress(manualAddress, { bias: { lat: appLocaLat, lng: appLocaLng } }); + if (place) handleDropPlaceSelected(place); + }, 800); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue3, dropCust.doorno, dropCust.suburb, dropCust.city, dropCust.postcode, appLocaLat, appLocaLng]); + // ============================================= || gettenantlocations (branches) || ============================================= const gettenantlocations = async () => { try { @@ -1749,7 +1792,7 @@ const Createorder1 = () => { placeholder="Select" value={dropCust.address} onChange={(e) => { - setPickCust({ ...dropCust, address: e.target.value }); + setDropCust({ ...dropCust, address: e.target.value }); if (e.target.value == '') { setAddId2(0); setShowDistance(false); diff --git a/yarn.lock b/yarn.lock index 57f1211..5457b28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7798,6 +7798,11 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open-location-code@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/open-location-code/-/open-location-code-1.0.3.tgz" + integrity sha512-DBm14BSn40Ee241n80zIFXIT6+y8Tb0I+jTdosLJ8Sidvr2qONvymwqymVbHV2nS+1gkDZ5eTNpnOIVV0Kn2fw== + open@^8.0.9, open@^8.4.0: version "8.4.0" resolved "https://registry.npmjs.org/open/-/open-8.4.0.tgz"