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'}
+
+
+ {
+ e.preventDefault();
+ setScopeOff((off) => !off);
+ }}
+ className="font-semibold text-blue-600 hover:underline cursor-pointer shrink-0"
+ >
+ {activeScope ? 'Search all cities' : `Only ${scope.city || 'this city'}`}
+
+
+ ) : 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 = ({
{
- setInputValue('');
- onChange?.('');
+ // Cleared text is always a cleared place, picked here or filled
+ // in by the page (a chosen customer's saved address).
+ const hadPick = Boolean(pickedNameRef.current);
+ editText('');
+ if (!hadPick) onPlaceCleared?.();
setOptions([]);
setIsOpen(false);
}}
@@ -315,13 +411,37 @@ const AddressAutocomplete = ({
+ {isOpen && loading && options.length === 0 && (
+
+
+ Searching{activeScope?.city ? ` in ${activeScope.city}` : ''}…
+
+ )}
+
{isOpen && noResults && options.length === 0 && !loading && (
-
No matching address found
+
+ {activeScope ? `No matching address in ${activeScope.city || 'this city'}` : 'No matching address found'}
+
+ {activeScope && (
+
{
+ e.preventDefault();
+ setScopeOff(true);
+ }}
+ className="mt-1 font-semibold text-blue-600 hover:underline cursor-pointer"
+ >
+ Search all cities
+
+ )}
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);