diff --git a/src/components/doormile/AddressAutocomplete.jsx b/src/components/doormile/AddressAutocomplete.jsx index 34b52b9..c0b15b0 100644 --- a/src/components/doormile/AddressAutocomplete.jsx +++ b/src/components/doormile/AddressAutocomplete.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; import { Clock, Compass, Crosshair, Loader2, MapPin, Navigation, X } from 'lucide-react'; import { + cityNamedIn, extractLandmark, getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, @@ -38,9 +39,17 @@ const AddressAutocomplete = ({ value, onChange, onPlaceSelected, + // Fired when the text stops describing the place that was picked — cleared, + // or rewritten to another area. The page should drop that place's pin: it + // used to stay, so the form saved one address with another address's pin. + onPlaceCleared, onOpenMapPin, onLandmarkExtracted, bias, + // A city scope from buildCityScope(): suggestions only from that city. The + // operator can widen one search to all cities from the list's footer, for + // an intercity drop. + scope, fullWidth = true, disabled = false, className = '', @@ -60,21 +69,100 @@ const AddressAutocomplete = ({ const wrapRef = useRef(null); const activeRef = useRef(0); - const skipNextSearchRef = useRef(false); + // True only while the text is the operator's typing. Text the page sets (a + // hub location filling the pickup, a chosen customer's address) or a picked + // suggestion is not searched, not even when the hub's city scope changes + // underneath it: that re-search opened an unasked-for list of + // "124 Gandhipuram Main Rd" matches on every Create Order load. + const typedRef = useRef(false); + // The in-flight search, aborted when a newer one starts. Ignoring stale + // answers was not enough: the requests still ran against the free services' + // allowance and slowed the one that mattered. + const abortRef = useRef(null); + // Name of the picked place. Typing that keeps it ("12/A, " + it) keeps the + // pin; text that no longer contains it means the pin no longer applies. + const pickedNameRef = useRef(''); + + const editText = (text) => { + typedRef.current = true; + setInputValue(text); + onChange?.(text); + const picked = pickedNameRef.current; + if (picked && !text.toLowerCase().includes(picked)) { + pickedNameRef.current = ''; + onPlaceCleared?.(); + } + }; + + useEffect(() => () => abortRef.current?.abort(), []); const listId = `${id || 'address'}-suggestions`; const optionId = (index) => `${listId}-option-${index}`; useEffect(() => { if (value !== undefined && value !== inputValue) { + typedRef.current = false; setInputValue(value || ''); } }, [value]); - const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''},${bias?.city ?? ''}`; + // "Search all cities" for this field. Reset when the scope itself changes — + // choosing another hub location means searching that city again. + const [scopeOff, setScopeOff] = useState(false); + const scopeCity = scope?.city || ''; + useEffect(() => setScopeOff(false), [scopeCity]); + const activeScope = scope && !scopeOff ? scope : undefined; + + const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''},${bias?.city ?? ''}|${activeScope ? activeScope.city : 'all'}`; + + // A city named at the end of the text ("gandhi nagar coimbatore") is searched + // in that city whatever the hub; the footer says so rather than naming the hub. + const typedCity = cityNamedIn(inputValue); + const scopeFooter = scope ? ( +
+ + + + {typedCity && typedCity !== scope.city + ? `Showing ${typedCity}, from your text` + : activeScope + ? `Showing ${scope.city || 'this hub’s city'} only` + : 'Showing all cities'} + + + +
+ ) : null; const fetchPredictions = useDebouncedCallback((query) => { const seq = ++activeRef.current; - getAddressSuggestions(query, { bias, limit: 12 }) + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + getAddressSuggestions(query, { + bias, + scope: activeScope, + limit: 12, + signal: controller.signal, + // Recents, Nominatim and India Post answer in ~1-2s; Photon can take 8s + // when throttled. Show the fast ones now; the spinner stays until the rest. + onPartial: (partial) => { + if (seq !== activeRef.current) return; + setOptions(partial); + setIsRecentMode(false); + setNoResults(false); + setIsOpen(true); + } + }) .then((results) => { if (seq !== activeRef.current) return; setLoading(false); @@ -109,13 +197,11 @@ const AddressAutocomplete = ({ // Trigger search when input changes useEffect(() => { - if (skipNextSearchRef.current) { - skipNextSearchRef.current = false; - return; - } + if (!typedRef.current) return; setNoResults(false); if (!inputValue || inputValue.trim().length < 2) { activeRef.current++; + abortRef.current?.abort(); const recents = getRecentAddresses(5); if (recents.length > 0 && (!inputValue || inputValue.length === 0)) { setOptions(recents); @@ -128,6 +214,12 @@ const AddressAutocomplete = ({ return; } + // Drop the previous search's list. It stayed on screen, clickable, while + // the new search ran: typing a Coimbatore address after a Bengaluru one + // showed Bengaluru results under "Showing Coimbatore only". + setOptions([]); + setIsRecentMode(false); + setIsOpen(true); setLoading(true); fetchPredictions(inputValue); }, [inputValue, biasKey]); @@ -148,8 +240,10 @@ const AddressAutocomplete = ({ const pick = (option) => { const formatted = option.formatted_address || option.name || ''; - skipNextSearchRef.current = true; + typedRef.current = false; activeRef.current++; + abortRef.current?.abort(); + pickedNameRef.current = String(option.name || formatted.split(',')[0] || '').trim().toLowerCase(); setInputValue(formatted); saveRecentAddress(option); onChange?.(formatted); @@ -212,9 +306,11 @@ const AddressAutocomplete = ({ } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1)); - } else if (e.key === 'Enter' && activeOption >= 0) { + } else if (e.key === 'Enter') { + // Enter with nothing highlighted takes the top suggestion, so the field + // can be filled without leaving the keyboard. e.preventDefault(); - pick(options[activeOption]); + pick(options[activeOption >= 0 ? activeOption : 0]); } }; @@ -269,10 +365,7 @@ const AddressAutocomplete = ({ aria-autocomplete="list" aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined} onFocus={handleFocus} - onChange={(e) => { - setInputValue(e.target.value); - onChange?.(e.target.value); - }} + onChange={(e) => editText(e.target.value)} onKeyDown={(e) => { onKeyDown(e); if (e.key === 'Escape') setIsOpen(false); @@ -290,8 +383,11 @@ const AddressAutocomplete = ({ + )}
Try just the area name or the 6-digit pincode {onOpenMapPin ? ( @@ -429,6 +549,7 @@ const AddressAutocomplete = ({ ); })} + {!isRecentMode && scopeFooter}
)} @@ -442,9 +563,11 @@ AddressAutocomplete.propTypes = { value: PropTypes.string, onChange: PropTypes.func, onPlaceSelected: PropTypes.func, + onPlaceCleared: PropTypes.func, onOpenMapPin: PropTypes.func, onLandmarkExtracted: PropTypes.func, bias: PropTypes.object, + scope: PropTypes.object, fullWidth: PropTypes.bool, disabled: PropTypes.bool, className: PropTypes.string, diff --git a/src/components/doormile/MapPinPickerModal.jsx b/src/components/doormile/MapPinPickerModal.jsx index 55d61fa..1956dd0 100644 --- a/src/components/doormile/MapPinPickerModal.jsx +++ b/src/components/doormile/MapPinPickerModal.jsx @@ -89,11 +89,15 @@ export default function MapPinPickerModal({ initialLat, initialLng, initialAddress = '', - title = 'Pin Exact Location on Map' + title = 'Pin Exact Location on Map', + // Where to open when there is no pin yet: the hub's city. It always opened + // on Bengaluru, so a Coimbatore operator started every pin 350 km away. + defaultCenter }) { - // Default coordinates (Bengaluru / Central fallback if none provided) - const defaultLat = Number(initialLat) || 12.9716; - const defaultLng = Number(initialLng) || 77.5946; + const fallbackLat = Number(defaultCenter?.lat) || 12.9716; + const fallbackLng = Number(defaultCenter?.lng) || 77.5946; + const defaultLat = Number(initialLat) || fallbackLat; + const defaultLng = Number(initialLng) || fallbackLng; const [coords, setCoords] = useState({ lat: defaultLat, lng: defaultLng }); const [mapCenter, setMapCenter] = useState([defaultLat, defaultLng]); @@ -109,8 +113,8 @@ export default function MapPinPickerModal({ useEffect(() => { if (isOpen) { - const lat = Number(initialLat) || 12.9716; - const lng = Number(initialLng) || 77.5946; + const lat = Number(initialLat) || fallbackLat; + const lng = Number(initialLng) || fallbackLng; setCoords({ lat, lng }); setMapCenter([lat, lng]); setZoomLevel(initialLat && initialLng ? 16 : 13); @@ -346,5 +350,6 @@ MapPinPickerModal.propTypes = { initialLat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), initialLng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), initialAddress: PropTypes.string, + defaultCenter: PropTypes.shape({ lat: PropTypes.number, lng: PropTypes.number }), title: PropTypes.string }; diff --git a/src/lib/geocodingService.js b/src/lib/geocodingService.js index 0facb1e..e0b8fbe 100644 --- a/src/lib/geocodingService.js +++ b/src/lib/geocodingService.js @@ -28,6 +28,7 @@ const getEnvVar = (key) => { const OLA_MAPS_KEY = getEnvVar('VITE_OLA_MAPS_API_KEY'); const INDIA_POST_PINCODE_URL = 'https://api.postalpincode.in/pincode'; +const INDIA_POST_POSTOFFICE_URL = 'https://api.postalpincode.in/postoffice'; const PHOTON_API_URL = 'https://photon.komoot.io/api'; const PHOTON_REVERSE_URL = 'https://photon.komoot.io/reverse'; const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; @@ -59,6 +60,83 @@ const SOUTH_METROS = [ { city: 'Chennai', lat: 13.0827, lng: 80.2707 } ]; +/** + * City scope: confine suggestions to the city of the hub/location an order is + * being booked from. A bias only ranked Coimbatore first and let Bengaluru and + * Chennai fill the rest of the list; a scope drops them. + * + * `pins` are the city's pincode prefixes, which is how India Post areas (no + * coordinates) are held to the city. Hub scoping elsewhere in the console is + * pincode-based on the same prefixes ('641%' for Coimbatore). + */ +const CITY_REGIONS = [ + { city: 'Coimbatore', names: ['coimbatore', 'kovai'], state: 'tamil nadu', lat: 11.0168, lng: 76.9558, pins: ['641', '642'] }, + { city: 'Bengaluru', names: ['bengaluru', 'bangalore'], state: 'karnataka', lat: 12.9716, lng: 77.5946, pins: ['560', '561', '562'] }, + { city: 'Chennai', names: ['chennai', 'madras'], state: 'tamil nadu', lat: 13.0827, lng: 80.2707, pins: ['600', '601', '602', '603'] }, + { city: 'Hyderabad', names: ['hyderabad', 'secunderabad', 'cyberabad'], state: 'telangana', lat: 17.385, lng: 78.4867, pins: ['500', '501', '502'] } +]; +// ~45 km each way: the metro and its outskirts (Karamadai, Hoskote, +// Guduvanchery, Shamshabad), not the next city. +const SCOPE_RADIUS_DEG = 0.4; + +/** + * The scope for a hub/location, or null when it can't be placed. Matches a + * known city by name, else by being within ~65 km of one; an unknown city is + * scoped around the location's own point and pincode prefix. + */ +export function buildCityScope({ latitude, longitude, city, pincode } = {}) { + const lat = Number(latitude); + const lng = Number(longitude); + const hasPoint = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0; + const name = String(city || '').trim().toLowerCase(); + const region = + (name && CITY_REGIONS.find((r) => r.names.some((n) => name.includes(n)))) || + (hasPoint && CITY_REGIONS.find((r) => Math.hypot(r.lat - lat, r.lng - lng) <= 0.6)) || + null; + const ownPrefix = /^\d{6}$/.test(String(pincode || '')) ? String(pincode).slice(0, 3) : ''; + if (region) { + return { + city: region.city, + lat: region.lat, + lng: region.lng, + radiusDeg: SCOPE_RADIUS_DEG, + pins: [...new Set([...region.pins, ...(ownPrefix ? [ownPrefix] : [])])], + names: region.names, + state: region.state + }; + } + if (!hasPoint) return null; + return { + city: String(city || '').trim(), + lat, + lng, + radiusDeg: SCOPE_RADIUS_DEG, + pins: ownPrefix ? [ownPrefix] : [], + names: name ? [name] : [] + }; +} + +/** Photon's bbox for a scope. */ +const scopeBbox = (s) => `${s.lng - s.radiusDeg},${s.lat - s.radiusDeg},${s.lng + s.radiusDeg},${s.lat + s.radiusDeg}`; + +/** Inside the scope: by point when the place has one, else by pincode, else by city name. */ +export function isInScope(place, scope) { + if (!scope) return true; + // The box around Coimbatore reaches Palakkad in Kerala; Bengaluru's reaches + // Hosur in Tamil Nadu. A place in another state is another city. + const state = String(place?.state || '').toLowerCase(); + if (scope.state && state && state !== scope.state) return false; + const lat = Number(place?.latitude); + const lng = Number(place?.longitude); + if (Number.isFinite(lat) && Number.isFinite(lng) && lat && lng) { + return Math.abs(lat - scope.lat) <= scope.radiusDeg && Math.abs(lng - scope.lng) <= scope.radiusDeg; + } + const pin = String(place?.postcode || ''); + if (/^\d{6}$/.test(pin) && scope.pins.length) return scope.pins.includes(pin.slice(0, 3)); + const city = String(place?.city || '').toLowerCase(); + return Boolean(city) && scope.names.some((n) => city.includes(n)); +} + export const isInSouth = (place) => { const state = String(place?.state || '').toLowerCase(); if (state) return SOUTH_STATES.includes(state); @@ -99,6 +177,12 @@ export const extractDoorPrefix = (text) => { if (match) { return { doorNo: match[1].trim(), cleanQuery: match[2].trim() }; } + // A bare leading number: "12/A MG Road", "4-2-17, Peelamedu", "12 Gandhi St". + // Not a pincode (6 digits alone) and not "100 Feet Road", which is a road. + const bare = trimmed.match(/^(\d{1,5}[a-z]?(?:\s*[/-]\s*[\da-z]+)*)(?:\s*,\s*|\s+)(?!(?:feet|ft)\b)(.+)$/i); + if (bare && !/^\d{6}$/.test(bare[1])) { + return { doorNo: bare[1].replace(/\s+/g, ''), cleanQuery: bare[2].trim() }; + } return { doorNo: '', cleanQuery: trimmed }; }; @@ -141,12 +225,49 @@ export const parseRawCoordinatesOrUrl = (text) => { // Nominatim routinely takes 3-5s from India. At 3s its answers were being // aborted and the dropdown showed Photon's alone — or nothing at all. const REQUEST_TIMEOUT_MS = 6000; -const fetchWithTimeout = (url, options = {}) => { +// `signal` is the caller's: the search box aborts it when the operator types +// on, so a stale search stops using the free services' request allowance. +// Photon's public server slows to 7-8s per request when it throttles (seen on +// 2026-09-28). At the shared 6s every Photon answer was abandoned and searches +// came back empty; the search box now shows the faster sources first, so +// Photon can be given longer to arrive. +const PHOTON_TIMEOUT_MS = 10000; +const fetchWithTimeout = (url, options = {}, signal, timeoutMs = REQUEST_TIMEOUT_MS) => { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + if (signal?.aborted) controller.abort(); + signal?.addEventListener?.('abort', onAbort); + return fetch(url, { ...options, signal: controller.signal }).finally(() => { + clearTimeout(timer); + signal?.removeEventListener?.('abort', onAbort); + }); }; +/** + * Area and city, the way an operator would write them. + * + * OSM files Coimbatore's areas under ward numbers ("Ward 24") and zones + * ("North Zone"), so the Suburb field was being filled with "Ward 41". Its city + * is sometimes a taluk ("RS Puram … Perur") or missing (Vadavalli), while the + * county is reliably " North/South/East/West". These read the real area + * and city out of whatever the geocoder returned. + */ +const ADMIN_NOISE = /^(ward\s*(no\.?)?\s*\d+|(north|south|east|west|central)\s+zone|zone\s*\d+)$/i; +const cleanArea = (value) => { + // "Ward 9 Ramanthapur" (Hyderabad) is the area Ramanthapur with its ward in front. + const v = String(value || '').trim().replace(/^ward\s*(no\.?)?\s*\d+\s+(?=\S)/i, ''); + return v && !ADMIN_NOISE.test(v) ? v : ''; +}; +const cityFromCounty = (county) => { + const c = String(county || '').trim(); + const directional = c.match(/^(.+?)\s+(north|south|east|west|central|urban|rural)$/i); + if (directional) return directional[1]; + return c.replace(/\s+(taluk|taluka|district|mandal)$/i, ''); +}; +const AREA_KINDS = new Set(['suburb', 'neighbourhood', 'quarter', 'locality', 'hamlet', 'village', 'residential', 'isolated_dwelling']); +const TOWN_KINDS = new Set(['city', 'town', 'municipality']); + /** * Standardize place result into unified format */ @@ -234,6 +355,70 @@ export const saveRecentAddress = (place) => { } catch {} }; +/** + * One Photon feature → a place, with the area and city an operator would write. + * Shared by search and reverse geocoding so both fill the form the same way. + */ +function photonToPlace(item, { doorNo = '', latitude, longitude, provider }) { + const p = item.properties || {}; + const kind = p.osm_value || ''; + const isArea = AREA_KINDS.has(kind) || p.type === 'locality' || p.type === 'district'; + const isTown = TOWN_KINDS.has(kind) || (p.type === 'city' && !AREA_KINDS.has(kind)); + + // The area: the place itself when it is one; else the locality a landmark or + // street sits in; never a ward number. + const area = + (isArea ? cleanArea(p.name) : '') || + cleanArea(p.locality) || + cleanArea(p.suburb) || + cleanArea(p.neighbourhood) || + cleanArea(p.district); + // City: " North/South" county beats a taluk in `city` ("Perur"). + const countyCity = cityFromCounty(p.county); + const city = + (/\s(north|south|east|west|central)$/i.test(p.county || '') ? countyCity : '') || + p.city || + (isTown ? p.name : '') || + countyCity || + ''; + + const house = doorNo || p.housenumber || ''; + const title = house ? `${house}, ${p.name || p.street || ''}`.replace(/,\s*$/, '') : p.name || ''; + const parts = [ + title, + p.street && p.street !== p.name ? `${!doorNo && p.housenumber ? `${p.housenumber} ` : ''}${p.street}` : '', + area !== p.name ? area : '', + city !== p.name ? city : '', + p.state || '', + p.postcode || '' + ].filter(Boolean); + const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); + + const place = standardizePlace({ + formatted_address: formatted || title, + name: title || formatted.split(',')[0], + suburb: area, + city, + state: p.state || '', + postcode: p.postcode || '', + latitude, + longitude, + provider, + raw: item + }); + // Bus stops, schools, police stations: real matches, but eight of them for + // "Vadavalli" left no room for any other locality. Ranking caps these. + // By what the place is, not whether it has a door number: "34-35, + // Gandhimanagar Police Station" has one and outranked Gandhi Nagar itself. + const poiKey = ['amenity', 'shop', 'tourism', 'leisure', 'office', 'railway', 'historic', 'craft', 'healthcare', 'man_made']; + place.isLandmark = + !isArea && + !isTown && + kind !== 'residential' && + (poiKey.includes(p.osm_key) || (p.osm_key === 'highway' && kind === 'bus_stop') || (p.type === 'house' && !p.housenumber)); + return place; +} + /** * 1. Photon (Komoot OSM) Dynamic Search */ @@ -255,9 +440,12 @@ async function searchPhoton(query, options = {}) { params.set('lon', String(bias.lng)); } - const res = await fetchWithTimeout(`${PHOTON_API_URL}?${params.toString()}`, { - headers: { Accept: 'application/json' } - }); + const res = await fetchWithTimeout( + `${PHOTON_API_URL}?${params.toString()}`, + { headers: { Accept: 'application/json' } }, + options.signal, + PHOTON_TIMEOUT_MS + ); if (!res.ok) return []; const data = await res.json(); @@ -272,49 +460,64 @@ async function searchPhoton(query, options = {}) { return isWithinIndia(lat, lon) && (!cc || cc === 'IN'); }) .map((item) => { - const p = item.properties || {}; const [lon, lat] = item.geometry?.coordinates || [null, null]; - const finalHouse = doorNo || p.housenumber || ''; - - const primaryName = finalHouse - ? `${finalHouse}, ${p.name || ''}`.trim().replace(/^,\s*/, '') - : p.name || ''; - - const parts = [ - primaryName, - p.street ? `${!doorNo && p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', - p.district || p.suburb || p.neighbourhood || p.locality, - p.city || p.county || '', - p.state || '', - p.postcode || '', - p.country || 'India' - ].filter(Boolean); - - const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx); - const formatted = uniqueParts.join(', '); - - return standardizePlace({ - formatted_address: formatted || primaryName, - name: primaryName || formatted.split(',')[0], - suburb: p.district || p.suburb || p.neighbourhood || p.locality || '', - city: p.city || p.county || '', - state: p.state || '', - postcode: p.postcode || '', - latitude: lat, - longitude: lon, - provider: 'photon', - raw: item - }); + return photonToPlace(item, { doorNo, latitude: lat, longitude: lon, provider: 'photon' }); }); } catch (err) { return []; } } +/** + * One Nominatim result → a place. Its display_name chains every admin level + * ("…North Zone, Coimbatore, Coimbatore North, Coimbatore, Tamil Nadu, India"), + * so the address is rebuilt from the parts an operator would write. + */ +function nominatimToPlace(r, { doorNo = '', provider, latitude = r.lat, longitude = r.lon }) { + const addr = r.address || {}; + const area = + cleanArea(addr.suburb) || + cleanArea(addr.neighbourhood) || + cleanArea(addr.quarter) || + cleanArea(addr.residential) || + cleanArea(addr.hamlet) || + cleanArea(addr.village) || + ''; + const city = addr.city || addr.town || cityFromCounty(addr.county) || addr.state_district || addr.village || ''; + const name = r.name || (r.display_name || '').split(',')[0] || ''; + const title = doorNo ? `${doorNo}, ${name}` : name; + const parts = [ + title, + addr.road && addr.road !== name ? addr.road : '', + area !== name ? area : '', + city !== name ? city : '', + addr.state || '', + addr.postcode || '' + ].filter(Boolean); + const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); + + const place = standardizePlace({ + formatted_address: formatted || r.display_name || '', + name: title, + suburb: area, + city, + state: addr.state || '', + postcode: addr.postcode || '', + latitude, + longitude, + provider, + raw: r + }); + place.isLandmark = + (['amenity', 'shop', 'tourism', 'leisure', 'office', 'building', 'railway'].includes(r.class) && r.type !== 'residential') || + (r.class === 'highway' && r.type === 'bus_stop'); + return place; +} + /** * 2. Nominatim Dynamic Search */ -async function searchNominatim(query, { bias, limit = 8 } = {}) { +async function searchNominatim(query, { bias, limit = 8, signal, scope } = {}) { try { const { doorNo, cleanQuery } = extractDoorPrefix(query); const searchQuery = cleanQuery || query; @@ -333,7 +536,12 @@ async function searchNominatim(query, { bias, limit = 8 } = {}) { params.set('q', searchQuery); } - if (bias?.lat && bias?.lng) { + if (scope && !isPincode) { + // Held to the city, not just preferring it. + const r = scope.radiusDeg; + params.set('viewbox', `${scope.lng - r},${scope.lat + r},${scope.lng + r},${scope.lat - r}`); + params.set('bounded', '1'); + } else if (bias?.lat && bias?.lng) { const d = 0.8; params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); } else { @@ -341,13 +549,17 @@ async function searchNominatim(query, { bias, limit = 8 } = {}) { params.set('viewbox', `${SOUTH_BOUNDS.minLng},${SOUTH_BOUNDS.maxLat},${SOUTH_BOUNDS.maxLng},${SOUTH_BOUNDS.minLat}`); } - const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { - headers: { - Accept: 'application/json', - 'Accept-Language': 'en-GB,en;q=0.9', - 'User-Agent': 'DoormileConsole/2.0' - } - }); + const res = await fetchWithTimeout( + `${NOMINATIM_SEARCH_URL}?${params.toString()}`, + { + headers: { + Accept: 'application/json', + 'Accept-Language': 'en-GB,en;q=0.9', + 'User-Agent': 'DoormileConsole/2.0' + } + }, + signal + ); if (!res.ok) return []; const results = await res.json(); @@ -355,35 +567,7 @@ async function searchNominatim(query, { bias, limit = 8 } = {}) { return results .filter((r) => isWithinIndia(r.lat, r.lon)) - .map((r) => { - const addr = r.address || {}; - const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || addr.district || ''; - const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || ''; - const state = addr.state || ''; - const postcode = addr.postcode || ''; - - let displayName = r.display_name || ''; - if (doorNo && !displayName.toLowerCase().startsWith(doorNo.toLowerCase())) { - displayName = `${doorNo}, ${displayName}`; - } - - const primaryName = doorNo - ? `${doorNo}, ${r.name || displayName.split(',')[0]}` - : r.name || displayName.split(',')[0]; - - return standardizePlace({ - formatted_address: displayName, - name: primaryName, - suburb, - city, - state, - postcode, - latitude: r.lat, - longitude: r.lon, - provider: 'nominatim', - raw: r - }); - }); + .map((r) => nominatimToPlace(r, { doorNo, provider: 'nominatim' })); } catch (err) { return []; } @@ -450,6 +634,47 @@ async function searchOlaMaps(query, { bias, limit = 5 } = {}) { * drop from its pin. null is "no pin yet", which every caller already handles. */ const pincodeCache = new Map(); +const postOfficeNameCache = new Map(); + +// Words that end many place names but are no place on their own: looking one +// up by name returns hundreds of unrelated villages across four states. +const GENERIC_AREA_WORDS = new Set([ + 'puram', 'nagar', 'palayam', 'palaiyam', 'pudur', 'puthur', 'patti', 'pettai', 'kottai', 'kulam', 'halli', + 'palya', 'pakkam', 'bakkam', 'road', 'street', 'salai', 'colony', 'layout', 'main', 'cross', 'north', 'south', + 'east', 'west', 'avenue', 'lane', 'phase', 'stage', 'block', 'sector', 'extension', 'extn', 'villas', 'villa', + 'apartments', 'apartment', 'residency', 'enclave', 'gardens', 'garden', 'towers', 'tower', 'city', 'town', + 'village', 'post', 'taluk', 'district', 'india', 'tamil', 'nadu', 'karnataka', 'kerala' +]); + +/** India Post rows → area-level places (no coordinates; see above). */ +const postOfficesToPlaces = (offices, { southOnly = false } = {}) => { + const seen = new Set(); + return (offices || []) + .filter((po) => { + if (!po?.Name || !/^\d{6}$/.test(String(po.Pincode || ''))) return false; + if (southOnly && !SOUTH_STATES.includes(String(po.State || '').toLowerCase())) return false; + const key = `${po.Name.toLowerCase()}|${po.Pincode}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map((po) => { + const area = po.Name.trim(); + const city = (po.District || po.Block || '').trim(); + const state = (po.State || '').trim(); + const pin = String(po.Pincode); + return standardizePlace({ + formatted_address: `${[area, city, state].filter(Boolean).join(', ')} ${pin}`, + name: area, + suburb: area, + city, + state, + postcode: pin, + provider: 'indiapost', + raw: po + }); + }); +}; export async function lookupPincode(pincode) { const pin = String(pincode || '').trim(); @@ -460,24 +685,7 @@ export async function lookupPincode(pincode) { if (!res.ok) return []; const data = await res.json(); const offices = Array.isArray(data) && data[0]?.Status === 'Success' ? data[0].PostOffice || [] : []; - const seen = new Set(); - const places = offices - .filter((po) => po?.Name && !seen.has(po.Name.toLowerCase()) && seen.add(po.Name.toLowerCase())) - .map((po) => { - const area = po.Name.trim(); - const city = (po.District || po.Block || '').trim(); - const state = (po.State || '').trim(); - return standardizePlace({ - formatted_address: `${[area, city, state].filter(Boolean).join(', ')} ${pin}`, - name: area, - suburb: area, - city, - state, - postcode: pin, - provider: 'indiapost', - raw: po - }); - }); + const places = postOfficesToPlaces(offices); pincodeCache.set(pin, places); return places; } catch { @@ -485,6 +693,64 @@ export async function lookupPincode(pincode) { } } +/** + * Post offices by area name, southern states only. + * + * OSM finds well-known localities (every one of 20 Bengaluru and 20 Chennai + * outskirts tested) but not small villages: Alandurai and Malumichampatti near + * Coimbatore are not places in OSM at all. India Post lists them, with the + * pincode, district and state. Area-level only, like lookupPincode. + */ +export async function lookupPostOfficeByName(name) { + const q = String(name || '').trim().toLowerCase(); + if (q.length < 4 || /\d/.test(q) || GENERIC_AREA_WORDS.has(q)) return []; + if (postOfficeNameCache.has(q)) return postOfficeNameCache.get(q); + try { + const res = await fetchWithTimeout(`${INDIA_POST_POSTOFFICE_URL}/${encodeURIComponent(q)}`); + if (!res.ok) return []; + const data = await res.json(); + const offices = Array.isArray(data) && data[0]?.Status === 'Success' ? data[0].PostOffice || [] : []; + // The API matches anywhere in the name ("puram" → Puramannur, Kerala; + // "Idikarai" → Pulidikarai, Dharmapuri). Only names that start with what + // was typed are what the operator meant. + const places = postOfficesToPlaces(offices, { southOnly: true }) + .filter((p) => p.name.toLowerCase().replace(/[^a-z]/g, '').startsWith(q.replace(/[^a-z]/g, ''))) + .slice(0, 6); + postOfficeNameCache.set(q, places); + return places; + } catch { + return []; + } +} + +/** + * The rough centre of a pincode, for checking that a geocoded address is + * where its pincode says. Bulk rows "Saravanampatti 641035" and "Alandurai + * 641101" were pinned in Trichy and Chennai: same names, other districts. + */ +const pincodeCentreCache = new Map(); +async function getPincodeCentre(pin, signal) { + if (!/^[1-9][0-9]{5}$/.test(pin || '')) return null; + if (pincodeCentreCache.has(pin)) return pincodeCentreCache.get(pin); + try { + const params = new URLSearchParams({ format: 'json', limit: '1', countrycodes: 'in', postalcode: pin }); + const res = await fetchWithTimeout( + `${NOMINATIM_SEARCH_URL}?${params.toString()}`, + { headers: { Accept: 'application/json', 'User-Agent': 'DoormileConsole/2.0' } }, + signal + ); + if (!res.ok) return null; + const [first] = await res.json(); + const centre = first && isWithinIndia(first.lat, first.lon) ? { lat: Number(first.lat), lng: Number(first.lon) } : null; + pincodeCentreCache.set(pin, centre); + return centre; + } catch { + return null; + } +} +// A pincode area in the South spans at most ~20 km; 0.25 degrees is ~27 km. +const PINCODE_RADIUS_DEG = 0.25; + /** * Spelling variants for a South Indian locality name. * @@ -537,7 +803,22 @@ const VARIANT_RADIUS_DEG = 0.6; // ~65 km /** * Deduplicates results and ranks by active city or geographic proximity */ -function deduplicateAndRank(places, bias, limit = 8, pin = '') { +// Consonant skeleton of a name, so the spellings of one Tamil/Kannada place +// compare equal: Saravanampatti / Sarvanampatti / Saravanampatty → "srvnmpt". +// Vowels after the first letter, y (patti/patty) and doubled letters drop out. +const skeleton = (s) => + String(s || '') + .toLowerCase() + .replace(/[^a-z]/g, '') + .replace(/(?!^)[aeiouy]/g, '') + .replace(/(.)\1+/g, '$1'); + +function deduplicateAndRank(places, bias, limit = 8, pin = '', word = '', { preferAreas = true } = {}) { + // `word` is the typed area ("saravanampatti"). OSM spells it "Sarvanampatti", + // so a plain prefix check ranked the police station named with the typed + // spelling above the area itself. + const stem = word.slice(0, 4); + const wordSkel = skeleton(word).slice(0, 5); const seen = new Set(); const unique = []; @@ -552,31 +833,57 @@ function deduplicateAndRank(places, bias, limit = 8, pin = '') { unique.push(p); } - // Rank: a typed pincode first, then the active city, then distance from the - // hub. City alone used to decide it, so when nothing matched the city name — - // the usual case for a small locality — proximity was never consulted. + // Rank, most important first: + // recent pick · typed pincode · in the South · names the typed area · + // in the hub's city · within ~65 km of the hub · (geocoding) not a landmark. + // Near-or-not rather than raw distance: raw distance sorted every Bengaluru + // match below every Coimbatore one and the limit cut them off. Within each + // bucket the providers' own relevance order stands. const biasCity = (bias?.city || '').toLowerCase(); const bLat = Number(bias?.lat); const bLng = Number(bias?.lng); const hasPoint = Number.isFinite(bLat) && Number.isFinite(bLng) && bLat !== 0 && bLng !== 0; + const namesArea = (p) => + (p.name || '').toLowerCase().replace(/\s+/g, '').includes(stem) || + (p.suburb || '').toLowerCase().includes(stem) || + (wordSkel.length >= 4 && (skeleton(p.name).includes(wordSkel) || skeleton(p.suburb).includes(wordSkel))); const score = (p) => [ p.provider === 'recent' ? 0 : 1, pin && String(p.postcode) !== pin ? 1 : 0, isInSouth(p) ? 0 : 1, + stem && !namesArea(p) ? 1 : 0, biasCity && !(p.city || p.formatted_address || '').toLowerCase().includes(biasCity) ? 1 : 0, - // Near the hub or not, rather than raw distance: raw distance sorted every - // Bengaluru match below every Coimbatore one and the limit cut them off. - // Within each bucket the providers' own relevance order stands. - hasPoint ? (p.latitude && p.longitude && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG ? 0 : 1) : 0 + hasPoint ? (p.latitude && p.longitude && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG ? 0 : 1) : 0, + // The area before a landmark named after it: "Indiranagar 560038" should + // land on Indiranagar, not the clinic, and Enter on "saravanampatti" took + // the police station. Last key, so a typed landmark name still wins + // through the names-the-typed-area key above. + preferAreas && p.isLandmark ? 1 : 0 ]; const scored = unique.map((p, i) => ({ p, i, k: score(p) })); scored.sort((a, b) => { for (let j = 0; j < a.k.length; j += 1) if (a.k[j] !== b.k[j]) return a.k[j] - b.k[j]; return a.i - b.i; }); - return scored.slice(0, limit).map((x) => x.p); + + // At most three landmarks (bus stops, schools, stations) while there is + // anything else to show: "Vadavalli" returned eight and no other locality. + const kept = []; + const overflow = []; + let landmarks = 0; + for (const { p } of scored) { + if (p.isLandmark && landmarks >= MAX_LANDMARKS) { + overflow.push(p); + } else { + if (p.isLandmark) landmarks += 1; + kept.push(p); + } + } + return [...kept, ...overflow].slice(0, limit); } +const MAX_LANDMARKS = 3; + /** Round-robin merge, so each city's best match shows before any city's fifth. */ const interleave = (lists) => { const out = []; @@ -586,24 +893,31 @@ const interleave = (lists) => { }; /** - * Photon around the hub (when one is chosen) AND around each southern metro. - * The hub's answers lead; the metros are searched regardless, so a Coimbatore - * operator typing a Bengaluru address still gets Bengaluru's matches. + * Photon for the search box. + * With a hub: around the hub, plus once across the South (2 requests), so a + * Coimbatore operator typing a Bengaluru address still sees Bengaluru. + * Without one: around Coimbatore, Bengaluru and Chennai, plus the South box + * (4 requests); one unbiased search for "Indiranagar" answers from + * Maharashtra. + * It was up to 19 requests for one search once the spelling fallback ran, + * which is what got the public server throttling us. */ async function searchPhotonSouth(query, options = {}) { - const hubLat = Number(options.bias?.lat); - const hubLng = Number(options.bias?.lng); - const hasHub = Boolean(hubLat && hubLng); + const hasHub = Boolean(Number(options.bias?.lat) && Number(options.bias?.lng)); const perCity = Math.max(4, Math.ceil((options.limit || 10) / 2)); - const metros = SOUTH_METROS.filter((m) => !hasHub || Math.hypot(m.lat - hubLat, m.lng - hubLng) > 0.5); const lists = await Promise.all([ - ...(hasHub ? [searchPhoton(query, { ...options, limit: perCity + 2 })] : []), - ...metros.map((m) => searchPhoton(query, { ...options, bias: { lat: m.lat, lng: m.lng }, limit: perCity })), + ...(hasHub + ? [searchPhoton(query, { ...options, limit: perCity + 2 })] + : SOUTH_METROS.map((m) => searchPhoton(query, { ...options, bias: { lat: m.lat, lng: m.lng }, limit: perCity }))), searchPhoton(query, { ...options, bias: undefined, bbox: SOUTH_BBOX_PHOTON, limit: perCity }) ]); return interleave(lists); } +/** One Photon search: near the hub if there is one, else boxed to the South. */ +const searchPhotonOnce = (query, options = {}) => + searchPhoton(query, options.bias?.lat ? options : { ...options, bbox: SOUTH_BBOX_PHOTON }); + /** Addresses this browser picked before whose text contains the query. */ function matchRecent(query, max = 3) { const q = query.toLowerCase(); @@ -613,12 +927,64 @@ function matchRecent(query, max = 3) { .map((r) => standardizePlace({ ...r, provider: 'recent' })); } +// Typing back over the same text, or reopening a form, repeats searches. Each +// costs several requests against free services that throttle, so answers are +// kept for ten minutes. +const SUGGESTION_TTL_MS = 10 * 60 * 1000; +const suggestionCache = new Map(); +const cacheGet = (key) => { + const hit = suggestionCache.get(key); + if (!hit || Date.now() - hit.at > SUGGESTION_TTL_MS) return null; + return hit.value; +}; +const cacheSet = (key, value) => { + if (suggestionCache.size > 200) suggestionCache.delete(suggestionCache.keys().next().value); + suggestionCache.set(key, { at: Date.now(), value }); +}; + +const collectSettled = (settled) => + settled.flatMap((res) => (res.status === 'fulfilled' && Array.isArray(res.value) ? res.value : [])); +const hasCoords = (p) => Boolean(p?.latitude && p?.longitude); +const normName = (s) => String(s || '').toLowerCase().replace(/[^a-z]/g, ''); + +/** The words of a query that name places: no door number, pincode or digits. */ +function queryWords(textQuery) { + const { cleanQuery: noDoor } = extractDoorPrefix(textQuery); + const words = (noDoor || textQuery).replace(/,+/g, ' ').split(/\s+/).filter((w) => w.length > 1 && !/\d/.test(w)); + const last = words[words.length - 1] || ''; + return { words, last, stem: last.toLowerCase().slice(0, 4) }; +} + +/** + * India Post areas are shown only where they add something: not when the map + * already has that area, and — with a hub chosen — only the hub's district + * when it has any ("Vadavalli" also named villages in Andhra and Erode). + */ +function usefulPostAreas(areas, mapPlaces, bias) { + const onMap = new Set(mapPlaces.filter(hasCoords).map((p) => `${normName(p.name)}|${normName(p.city)}`)); + const fresh = areas.filter((a) => !onMap.has(`${normName(a.name)}|${normName(a.city)}`)); + const city = normName(bias?.city); + const local = city ? fresh.filter((a) => normName(a.city) === city) : []; + return (local.length ? local : fresh).slice(0, 3); +} + /** * Primary Address Autocomplete Dispatcher + * + * Two modes, because two callers want different things: + * 'suggest' (default) — the search box. Wide: several Photon searches, + * recent picks, India Post areas (which have no map point), spelling + * guesses. `onPartial(results)` receives the fast sources (recents, + * Nominatim, India Post, under ~2s) before Photon, which can take 7-8s + * when its public server throttles. + * 'geocode' — geocodeAddress, for bulk upload and the assistant, which take + * the top answer and price from its pin. See geocodeOne. */ export async function getAddressSuggestions(query, options = {}) { const cleanQuery = (query || '').trim(); if (!cleanQuery) return []; + const mode = options.mode === 'geocode' ? 'geocode' : 'suggest'; + const { signal, onPartial } = options; // Check if user pasted coordinates or Google Maps link directly const parsedCoords = parseRawCoordinatesOrUrl(cleanQuery); @@ -630,76 +996,177 @@ export async function getAddressSuggestions(query, options = {}) { if (cleanQuery.length < 2) return []; const limit = options.limit || 12; + const biasKey = `${options.bias?.lat ?? ''},${options.bias?.lng ?? ''},${options.bias?.city ?? ''}`; + const scopeKey = options.scope ? `${options.scope.city}@${options.scope.lat},${options.scope.lng}` : ''; + const cacheKey = `${mode}|${limit}|${cleanQuery.toLowerCase()}|${biasKey}|${scopeKey}`; + const cached = cacheGet(cacheKey); + if (cached) return cached; + const pinMatch = cleanQuery.match(/(?:^|\D)([1-9][0-9]{5})(?:\D|$)/); + const pin = pinMatch ? pinMatch[1] : ''; const pinOnly = /^[1-9][0-9]{5}$/.test(cleanQuery); - const collect = (settled) => - settled.flatMap((res) => (res.status === 'fulfilled' && Array.isArray(res.value) ? res.value : [])); + // The pincode ranks the results; sent inside the text it only confuses OSM. + const textQuery = + pin && !pinOnly + ? cleanQuery.replace(pin, ' ').replace(/[\s,]+$/, '').replace(/\s{2,}/g, ' ').trim() + : cleanQuery; + const opts = { ...options, signal, onPartial: undefined }; + + const result = + mode === 'geocode' + ? await geocodeOne(textQuery, pin, pinOnly, opts, limit) + : await suggest(textQuery, pin, pinOnly, opts, limit, onPartial); + + // An aborted search returns whatever had finished; that is not the answer. + if (!signal?.aborted) cacheSet(cacheKey, result); + return result; +} + +/** + * "gandhi nagar coimbatore": the last word names a city we serve. Search the + * rest inside that city. Treated as the area word, "coimbatore" ranked + * Coimbatore Aerodrome and Coimbatore Bazaar above Gandhi Nagar; and it is how + * an operator on a Hyderabad hub reaches a Coimbatore drop without a click. + */ +/** The served city named at the end of the text, if any ("…, bangalore" → "Bengaluru"). */ +export function cityNamedIn(text) { + return splitCityHint(text)?.scope?.city || ''; +} + +function splitCityHint(text) { + const m = String(text || '').match(/^(.*\S)[\s,]+([a-z]+)\s*$/i); + if (!m) return null; + const word = m[2].toLowerCase(); + const region = CITY_REGIONS.find((r) => r.names.includes(word)); + if (!region || m[1].replace(/[\s,]/g, '').length < 3) return null; + return { rest: m[1].replace(/[\s,]+$/, ''), scope: buildCityScope({ city: region.city }) }; +} + +async function suggest(textQuery, pin, pinOnly, opts, limit, onPartial) { + const hint = pinOnly ? null : splitCityHint(textQuery); + if (hint) { + textQuery = hint.rest; + opts = { ...opts, scope: hint.scope, bias: { lat: hint.scope.lat, lng: hint.scope.lng, city: hint.scope.city } }; + } + const { words, last, stem } = queryWords(textQuery); + const partial = (list) => { + if (onPartial && !opts.signal?.aborted && list.length) onPartial(list); + }; + + // A city scope (Create Order's selected hub location) holds every source to + // that city. A typed pincode is an explicit choice and is never scoped. + const scope = pinOnly ? null : opts.scope || null; + const inScope = (list) => (scope ? list.filter((p) => isInScope(p, scope)) : list); + // Rank around the scope's city, not the location's name ("RS Puram Kitchen"). + const bias = scope ? { lat: scope.lat, lng: scope.lng, city: scope.city } : opts.bias; + const photonWide = (q, o) => + scope + ? searchPhoton(q, { ...o, bias: { lat: scope.lat, lng: scope.lng }, bbox: scopeBbox(scope), limit: Math.max(o.limit || 10, 10) }) + : searchPhotonSouth(q, o); + const photonOne = (q, o) => + scope ? searchPhoton(q, { ...o, bias: { lat: scope.lat, lng: scope.lng }, bbox: scopeBbox(scope) }) : searchPhotonOnce(q, o); - // A bare pincode is answered from the pincode directory first: its areas are - // what the operator is choosing between. OSM's centroid follows as the one - // result that carries a map point. if (pinOnly) { - const [areas, osm] = await Promise.all([ - lookupPincode(cleanQuery), - Promise.allSettled([searchPhotonSouth(cleanQuery, options), searchNominatim(cleanQuery, options)]).then(collect) - ]); - return deduplicateAndRank([...areas, ...osm], options.bias, Math.max(limit, areas.length + 1), cleanQuery); + // A bare pincode: its areas are what the operator is choosing between; + // OSM's centroid follows as the one result that carries a map point. + const photonP = searchPhotonOnce(textQuery, opts); + const [areas, nomi] = await Promise.all([lookupPincode(textQuery), searchNominatim(textQuery, opts)]); + const size = Math.max(limit, areas.length + 1); + partial(deduplicateAndRank([...areas, ...nomi], opts.bias, size, textQuery)); + const osm = [...nomi, ...(await photonP)]; + return deduplicateAndRank([...areas, ...osm], opts.bias, size, textQuery); } - const promises = []; - if (OLA_MAPS_KEY) { - promises.push(searchOlaMaps(cleanQuery, options)); - } - promises.push(Promise.resolve(matchRecent(cleanQuery))); - promises.push(searchPhotonSouth(cleanQuery, options)); - promises.push(searchNominatim(cleanQuery, options)); - // "white villas 641035" — the pincode's areas are useful alongside the text match. - if (pinMatch) promises.push(lookupPincode(pinMatch[1])); + // Photon starts first and is awaited last: it is the slowest source. + const photonP = photonWide(textQuery, opts); + const olaP = OLA_MAPS_KEY ? searchOlaMaps(textQuery, opts) : Promise.resolve([]); + const [recent, nomi, byName, byPin] = await Promise.all([ + Promise.resolve(inScope(matchRecent(textQuery))), + searchNominatim(textQuery, opts), + // Small villages OSM lacks (Alandurai, Malumichampatti): India Post by name. + last.length >= 4 ? lookupPostOfficeByName(last) : Promise.resolve([]), + // "white villas 641035": the pincode's areas alongside the text match. + pin ? lookupPincode(pin) : Promise.resolve([]) + ]); + // The pincode's area named in the query always shows; other India Post areas + // only where the map places found so far don't already cover them. + const postAreas = (mapPlaces) => { + const named = pin ? byPin.filter((a) => textQuery.toLowerCase().includes(a.name.toLowerCase())) : []; + const others = inScope([...byName, ...byPin.filter((a) => !named.includes(a))]); + return [...named, ...usefulPostAreas(others, mapPlaces, bias)]; + }; + partial(deduplicateAndRank([...recent, ...inScope(nomi), ...postAreas([...recent, ...nomi])], bias, limit, pin, last.toLowerCase())); - let places = collect(await Promise.allSettled(promises)); - if (pinMatch) { - // Of the pincode's areas, the one named in the query goes first. - const q = cleanQuery.toLowerCase(); - const named = (p) => (p.provider === 'indiapost' && q.includes(p.name.toLowerCase()) ? 0 : 1); - places = places.map((p, i) => ({ p, i })).sort((a, b) => named(a.p) - named(b.p) || a.i - b.i).map((x) => x.p); - } + let map = inScope([...collectSettled(await Promise.allSettled([olaP, photonP])), ...nomi]); // OSM matches whole phrases poorly: "white villas manikarapalayam" finds a // villa estate in Bangalore and nothing in the locality that was typed. - // Indian addresses end with the area, so when the full phrase comes back thin, - // search the trailing words on their own — Photon's fuzzy match then finds - // the locality even with a spelling slip. "Thin" means few results that - // mention the area at all, not few results: Bangalore villas are plenty. - const words = cleanQuery.replace(/[,]+/g, ' ').split(/\s+/).filter((w) => w.length > 1 && !/^\d+$/.test(w)); - if (words.length >= 1) { - const stem = words[words.length - 1].toLowerCase().slice(0, 5); - const mentions = (p) => (p.formatted_address || '').toLowerCase().includes(stem); - const relevant = places.filter(mentions); - if (relevant.length < 2) { - const last = words[words.length - 1]; - // One word is the query itself; re-searching it would only repeat it. - const tails = words.length >= 2 ? [words.slice(-2).join(' '), last].filter((t, i, a) => a.indexOf(t) === i) : []; - const variants = localitySpellings(last); - const [extra, guessed] = await Promise.all([ - Promise.allSettled(tails.map((t) => searchPhotonSouth(t, { ...options, limit: 6 }))).then(collect), - // Without a hub, guesses are boxed to the South rather than run per metro. - Promise.allSettled( - variants.map((v) => - searchPhoton(v, { ...options, limit: 4, ...(options.bias?.lat ? {} : { bbox: SOUTH_BBOX_PHOTON }) }) - ) - ).then(collect) - ]); - const bLat = Number(options.bias?.lat); - const bLng = Number(options.bias?.lng); - const near = (p) => - bLat && bLng - ? Boolean(p.latitude && p.longitude && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG) - : isInSouth(p); - places = [...relevant, ...guessed.filter(near), ...extra, ...places.filter((p) => !mentions(p))]; - } + // Indian addresses end with the area, so when the pinned results barely + // mention it, search the trailing words alone and a few spellings of it. + // One Photon request each (was a four-city fan-out each). + const mentions = (p) => (p.formatted_address || '').toLowerCase().includes(stem); + const relevant = map.filter(mentions); + if (last && relevant.filter(hasCoords).length < 2 && !opts.signal?.aborted) { + // Show what the map found while the guesses run. + partial(deduplicateAndRank([...recent, ...map, ...postAreas([...recent, ...map])], bias, limit, pin, last.toLowerCase())); + // The area word alone and its spellings. Not the last two words: "villas + // manikarapalayam" is no better than the full line and costs a request + // on a server that may already be throttling. + const tries = [words.length >= 2 ? last : '', ...localitySpellings(last).slice(0, 3)].filter(Boolean); + const extra = inScope(collectSettled(await Promise.allSettled(tries.map((t) => photonOne(t, { ...opts, limit: 5 }))))); + const bLat = Number(opts.bias?.lat); + const bLng = Number(opts.bias?.lng); + const near = (p) => + scope + ? isInScope(p, scope) + : bLat && bLng + ? Boolean(hasCoords(p) && Math.hypot(p.latitude - bLat, p.longitude - bLng) <= VARIANT_RADIUS_DEG) + : isInSouth(p); + const guessed = extra.filter((p) => mentions(p) || near(p)); + map = [...relevant, ...guessed, ...map.filter((p) => !mentions(p))]; } - return deduplicateAndRank(places, options.bias, limit, pinMatch ? pinMatch[1] : ''); + return deduplicateAndRank([...recent, ...map, ...postAreas([...recent, ...map])], bias, limit, pin, last.toLowerCase()); +} + +/** + * geocodeAddress's search: one address in, the best place WITH a pin out. + * + * It used to share the search box's wide search, and a bulk row carrying a + * pincode came back as a pincode area with no pin, after ~6s and ~8 + * requests. Now: + * - Nominatim first (answers in under a second, and one lookup per row is + * within its policy; the bulk flow already spaces rows out). Photon only + * when Nominatim has nothing usable. + * - With a pincode, only places within ~27 km of that pincode's centre: + * same-named villages in other districts are refused. + * - An area beats a landmark that happens to contain its name. + * - No recent picks and no India Post areas (neither is this address's pin). + */ +async function geocodeOne(textQuery, pin, pinOnly, opts, limit) { + const { words, last, stem } = queryWords(textQuery); + const centre = pin ? await getPincodeCentre(pin, opts.signal) : null; + const inPincode = (p) => + hasCoords(p) && + (!centre || String(p.postcode) === pin || Math.hypot(p.latitude - centre.lat, p.longitude - centre.lng) <= PINCODE_RADIUS_DEG); + const rank = (list) => deduplicateAndRank(list.filter(inPincode), opts.bias, limit, pin, last.toLowerCase(), { preferAreas: true }); + + const first = rank(await searchNominatim(textQuery, opts)); + if (first.length && (pinOnly || !stem || first[0].formatted_address.toLowerCase().includes(stem))) return first; + + const photon = await searchPhotonOnce(textQuery, centre ? { ...opts, bias: centre } : opts); + let found = rank([...first, ...photon]); + if (!found.length && last && !opts.signal?.aborted) { + // The full line matched nothing near the pincode: try the area on its own + // and its likeliest spellings, still held to the pincode. + const tries = [words.length >= 2 ? words.slice(-2).join(' ') : '', ...localitySpellings(last).slice(0, 2), last] + .filter((t, i, a) => t && a.indexOf(t) === i); + const more = collectSettled( + await Promise.allSettled(tries.map((t) => searchPhotonOnce(t, centre ? { ...opts, bias: centre, limit: 5 } : { ...opts, limit: 5 }))) + ); + found = rank(more); + } + return found; } /** @@ -711,44 +1178,10 @@ export async function reverseGeocode(lat, lon) { const nLon = Number(lon); if (!isWithinIndia(nLat, nLon)) return null; - // 1. Try Photon reverse - try { - const res = await fetchWithTimeout(`${PHOTON_REVERSE_URL}?lat=${nLat}&lon=${nLon}`, { - headers: { Accept: 'application/json' } - }); - if (res.ok) { - const data = await res.json(); - if (data?.features?.length > 0) { - const item = data.features[0]; - const p = item.properties || {}; - const parts = [ - p.name, - p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', - p.district || p.suburb || p.neighbourhood || p.locality, - p.city || p.county || '', - p.state || '', - p.postcode || '', - p.country || 'India' - ].filter(Boolean); - - const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); - - return standardizePlace({ - formatted_address: formatted, - name: p.name || formatted.split(',')[0], - suburb: p.district || p.suburb || p.neighbourhood || p.locality || '', - city: p.city || p.county || '', - state: p.state || '', - postcode: p.postcode || '', - latitude: nLat, - longitude: nLon, - provider: 'photon_reverse' - }); - } - } - } catch (e) {} - - // 2. Fallback to Nominatim reverse + // 1. Nominatim reverse. One lookup per dropped pin is within its policy and + // it answers in under a second; Photon's reverse took over 6s when its + // public server throttled (2026-09-28), so the pin sat on "Identifying + // street address…" until Photon timed out and this ran anyway. try { const res = await fetchWithTimeout( `${NOMINATIM_REVERSE_URL}?lat=${nLat}&lon=${nLon}&format=json&zoom=18&addressdetails=1`, @@ -762,20 +1195,28 @@ export async function reverseGeocode(lat, lon) { ); if (res.ok) { const r = await res.json(); - const addr = r.address || {}; - return standardizePlace({ - formatted_address: r.display_name || '', - name: r.display_name?.split(',')[0] || '', - suburb: addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || addr.district || '', - city: addr.city || addr.town || addr.village || addr.city_district || addr.county || '', - state: addr.state || '', - postcode: addr.postcode || '', - latitude: nLat, - longitude: nLon, - provider: 'nominatim_reverse' - }); + if (r && !r.error) { + // The pin stays where it was dropped; only the address is read. + return nominatimToPlace(r, { provider: 'nominatim_reverse', latitude: nLat, longitude: nLon }); + } } - } catch (err) {} + } catch {} + + // 2. Photon reverse, when Nominatim has nothing or is down. + try { + const res = await fetchWithTimeout( + `${PHOTON_REVERSE_URL}?lat=${nLat}&lon=${nLon}`, + { headers: { Accept: 'application/json' } }, + undefined, + PHOTON_TIMEOUT_MS + ); + if (res.ok) { + const data = await res.json(); + if (data?.features?.length > 0) { + return photonToPlace(data.features[0], { latitude: nLat, longitude: nLon, provider: 'photon_reverse' }); + } + } + } catch {} return standardizePlace({ formatted_address: `Location (${nLat.toFixed(5)}, ${nLon.toFixed(5)})`, @@ -790,6 +1231,6 @@ export async function reverseGeocode(lat, lon) { * Direct forward geocode */ export async function geocodeAddress(address, options = {}) { - const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 }); + const suggestions = await getAddressSuggestions(address, { ...options, limit: 1, mode: 'geocode' }); return suggestions.length > 0 ? suggestions[0] : null; } diff --git a/src/pages/doormile/clients/CreateCustomer.jsx b/src/pages/doormile/clients/CreateCustomer.jsx index e66ca87..9fad4f3 100644 --- a/src/pages/doormile/clients/CreateCustomer.jsx +++ b/src/pages/doormile/clients/CreateCustomer.jsx @@ -304,6 +304,7 @@ export default function CreateCustomer() { bias={bias} onChange={(text) => set('address')(text)} onPlaceSelected={applyPlace} + onPlaceCleared={() => setForm((current) => ({ ...current, latitude: '', longitude: '', suburb: '', postcode: '' }))} onOpenMapPin={() => setIsMapOpen(true)} onLandmarkExtracted={(lmk) => setForm((current) => (current.landmark ? current : { ...current, landmark: lmk }))} /> diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx index 35c0b26..22582bd 100644 --- a/src/pages/doormile/orders/CreateOrder.jsx +++ b/src/pages/doormile/orders/CreateOrder.jsx @@ -57,7 +57,7 @@ import { calculateDrivingRoute, calculateTotalCharge } from '@/lib/distance'; -import { geocodeAddress, saveRecentAddress } from '@/lib/geocodingService'; +import { buildCityScope, geocodeAddress, saveRecentAddress } from '@/lib/geocodingService'; import { useHubs } from '@/lib/doormileHooks'; import { PICKUP_SOURCE, @@ -618,13 +618,33 @@ export default function CreateOrder() { return Math.max(base, calculated); }, [route, clientRate]); + // The selected hub location's city. Address suggestions come only from it: + // a Coimbatore location searches Coimbatore, a Bengaluru or Hyderabad one + // searches that city. It used to be a bias alone, which ranked the hub's + // city first and still filled the list with Bengaluru and Chennai. An + // intercity drop widens its own search from the suggestion list's footer. + const cityScope = useMemo( + () => + selectedLocation + ? buildCityScope({ + latitude: selectedLocation.latitude ?? selectedLocation.lat, + longitude: selectedLocation.longitude ?? selectedLocation.lng, + city: selectedLocation.city, + pincode: selectedLocation.pincode || selectedLocation.postcode + }) + : null, + [selectedLocation] + ); + // Active City & Hub Bias for smart address autocomplete const activeCityBias = useMemo(() => { if (selectedLocation?.latitude && selectedLocation?.longitude) { return { lat: Number(selectedLocation.latitude), lng: Number(selectedLocation.longitude), - city: selectedLocation.city || selectedLocation.locationname || '' + // Not the location's name: "RS Puram Kitchen" is not a city, and the + // ranking compares this against each suggestion's city. + city: selectedLocation.city || cityScope?.city || '' }; } if (pickCust?.latitude && pickCust?.longitude) { @@ -635,7 +655,7 @@ export default function CreateOrder() { }; } return undefined; - }, [selectedLocation, pickCust.latitude, pickCust.longitude, pickCust.city]); + }, [selectedLocation, cityScope, pickCust.latitude, pickCust.longitude, pickCust.city]); // Place selection helpers const handlePlaceSelect = (partyType, place) => { @@ -1141,8 +1161,10 @@ export default function CreateOrder() { placeholder="Search area, street, society, pincode or paste link…" value={pickCust.address} bias={activeCityBias} + scope={cityScope} onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))} onPlaceSelected={(place) => handlePlaceSelect('pickup', place)} + onPlaceCleared={() => setPickCust((prev) => ({ ...prev, latitude: '', longitude: '', suburb: '', postcode: '' }))} onOpenMapPin={() => setMapPickerTarget('pickup')} onLandmarkExtracted={(lmk) => { setPickCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev)); @@ -1177,7 +1199,8 @@ export default function CreateOrder() { type="text" placeholder="Pincode" value={pickCust.postcode} - onChange={(e) => setPickCust({ ...pickCust, postcode: e.target.value })} + onChange={(e) => setPickCust({ ...pickCust, postcode: e.target.value.replace(/\D/g, '').slice(0, 6) })} + inputMode="numeric" className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors font-mono" /> @@ -1276,8 +1299,10 @@ export default function CreateOrder() { placeholder="Search area, street, society, pincode or paste link…" value={dropCust.address} bias={activeCityBias} + scope={cityScope} onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))} onPlaceSelected={(place) => handlePlaceSelect('drop', place)} + onPlaceCleared={() => setDropCust((prev) => ({ ...prev, latitude: '', longitude: '', suburb: '', postcode: '' }))} onOpenMapPin={() => setMapPickerTarget('drop')} onLandmarkExtracted={(lmk) => { setDropCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev)); @@ -1312,7 +1337,8 @@ export default function CreateOrder() { type="text" placeholder="Pincode" value={dropCust.postcode} - onChange={(e) => setDropCust({ ...dropCust, postcode: e.target.value })} + onChange={(e) => setDropCust({ ...dropCust, postcode: e.target.value.replace(/\D/g, '').slice(0, 6) })} + inputMode="numeric" className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors font-mono" /> @@ -1934,6 +1960,7 @@ export default function CreateOrder() { initialAddress={mapPickerTarget === 'pickup' ? pickCust.address : dropCust.address} initialLat={mapPickerTarget === 'pickup' ? pickCust.latitude : dropCust.latitude} initialLng={mapPickerTarget === 'pickup' ? pickCust.longitude : dropCust.longitude} + defaultCenter={cityScope ? { lat: cityScope.lat, lng: cityScope.lng } : undefined} onConfirm={(place) => { if (mapPickerTarget) { handlePlaceSelect(mapPickerTarget, place);