diff --git a/src/components/doormile/AddressAutocomplete.jsx b/src/components/doormile/AddressAutocomplete.jsx
index 488a8ce..34b52b9 100644
--- a/src/components/doormile/AddressAutocomplete.jsx
+++ b/src/components/doormile/AddressAutocomplete.jsx
@@ -54,6 +54,9 @@ const AddressAutocomplete = ({
const [isOpen, setIsOpen] = useState(false);
const [activeOption, setActiveOption] = useState(-1);
const [isRecentMode, setIsRecentMode] = useState(false);
+ // A finished search that found nothing. The list used to just close, which
+ // reads as "the box is broken" rather than "try a pincode or the map".
+ const [noResults, setNoResults] = useState(false);
const wrapRef = useRef(null);
const activeRef = useRef(0);
@@ -71,21 +74,25 @@ const AddressAutocomplete = ({
const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current;
- getAddressSuggestions(query, { bias, limit: 8 })
+ getAddressSuggestions(query, { bias, limit: 12 })
.then((results) => {
if (seq !== activeRef.current) return;
setLoading(false);
setOptions(results || []);
setIsRecentMode(false);
- setIsOpen((results || []).length > 0);
+ setNoResults(!(results || []).length);
+ setIsOpen(true);
})
.catch(() => {
if (seq !== activeRef.current) return;
setLoading(false);
setOptions([]);
- setIsOpen(false);
+ setNoResults(true);
+ setIsOpen(true);
});
- }, 200);
+ // 350ms, not 200: every pause fires a Nominatim request, and Nominatim
+ // allows one a second before it starts refusing.
+ }, 350);
// Handle Focus: Show Recent Addresses if field is empty
const handleFocus = () => {
@@ -106,6 +113,7 @@ const AddressAutocomplete = ({
skipNextSearchRef.current = false;
return;
}
+ setNoResults(false);
if (!inputValue || inputValue.trim().length < 2) {
activeRef.current++;
const recents = getRecentAddresses(5);
@@ -307,6 +315,35 @@ const AddressAutocomplete = ({
+ {isOpen && noResults && options.length === 0 && !loading && (
+
+
No matching address found
+
+ Try just the area name or the 6-digit pincode
+ {onOpenMapPin ? (
+ <>
+ , or{' '}
+
+ >
+ ) : null}
+ .
+
+
+ )}
+
{isOpen && options.length > 0 && (
{options.map((option, index) => {
const isHighlight = index === activeOption;
- const mainTitle = option.name || option.formatted_address.split(',')[0];
+ const mainTitle = option.name || (option.formatted_address || '').split(',')[0];
+ const areaOnly = option.provider === 'indiapost';
return (
+ {option.provider === 'recent' && !isRecentMode && (
+
+ Recent
+
+ )}
+
{option.suburb && option.suburb !== mainTitle && (
{option.suburb}
@@ -374,6 +418,11 @@ const AddressAutocomplete = ({
{option.formatted_address}
+ {areaOnly && (
+
+ Pincode area. Fills city and state; pin the exact spot on the map.
+
+ )}
diff --git a/src/lib/geocodingService.js b/src/lib/geocodingService.js
index 50024fb..0facb1e 100644
--- a/src/lib/geocodingService.js
+++ b/src/lib/geocodingService.js
@@ -27,6 +27,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 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';
@@ -41,6 +42,34 @@ const INDIA_BOUNDS = {
};
const INDIA_BBOX_PHOTON = '68.0,6.5,97.5,37.5';
+// Doormile operates in the South. Tamil Nadu, Karnataka, Kerala, Andhra,
+// Telangana and Puducherry all sit inside this box (Hyderabad at 17.4N).
+const SOUTH_BOUNDS = { minLat: 8.0, maxLat: 19.95, minLng: 74.0, maxLng: 84.8 };
+const SOUTH_BBOX_PHOTON = `${SOUTH_BOUNDS.minLng},${SOUTH_BOUNDS.minLat},${SOUTH_BOUNDS.maxLng},${SOUTH_BOUNDS.maxLat}`;
+const SOUTH_STATES = ['tamil nadu', 'karnataka', 'kerala', 'andhra pradesh', 'telangana', 'puducherry', 'pondicherry'];
+
+// With no hub selected, the query is searched around each of these at once.
+// Photon ranks by closeness to the point it is given, so one unbiased search
+// for "Indiranagar" answers from Maharashtra; three biased ones return
+// Bengaluru's, Chennai's and Coimbatore's. Other southern cities are covered
+// by one more search boxed to the South.
+const SOUTH_METROS = [
+ { city: 'Coimbatore', lat: 11.0168, lng: 76.9558 },
+ { city: 'Bengaluru', lat: 12.9716, lng: 77.5946 },
+ { city: 'Chennai', lat: 13.0827, lng: 80.2707 }
+];
+
+export const isInSouth = (place) => {
+ const state = String(place?.state || '').toLowerCase();
+ if (state) return SOUTH_STATES.includes(state);
+ const lat = Number(place?.latitude);
+ const lng = Number(place?.longitude);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng) || (!lat && !lng)) {
+ return SOUTH_STATES.some((st) => String(place?.formatted_address || '').toLowerCase().includes(st));
+ }
+ return lat >= SOUTH_BOUNDS.minLat && lat <= SOUTH_BOUNDS.maxLat && lng >= SOUTH_BOUNDS.minLng && lng <= SOUTH_BOUNDS.maxLng;
+};
+
const RECENT_ADDRESSES_STORAGE_KEY = 'doormile_recent_addresses';
/**
@@ -109,7 +138,9 @@ export const parseRawCoordinatesOrUrl = (text) => {
return null;
};
-const REQUEST_TIMEOUT_MS = 3000;
+// 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 = {}) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -206,7 +237,8 @@ export const saveRecentAddress = (place) => {
/**
* 1. Photon (Komoot OSM) Dynamic Search
*/
-async function searchPhoton(query, { bias, limit = 10 } = {}) {
+async function searchPhoton(query, options = {}) {
+ const { bias, limit = 10 } = options;
try {
const { doorNo, cleanQuery } = extractDoorPrefix(query);
const searchQuery = cleanQuery || query;
@@ -215,7 +247,7 @@ async function searchPhoton(query, { bias, limit = 10 } = {}) {
q: searchQuery,
limit: String(limit),
lang: 'en',
- bbox: INDIA_BBOX_PHOTON
+ bbox: options.bbox || INDIA_BBOX_PHOTON
});
if (bias?.lat && bias?.lng) {
@@ -234,7 +266,10 @@ async function searchPhoton(query, { bias, limit = 10 } = {}) {
return data.features
.filter((item) => {
const [lon, lat] = item.geometry?.coordinates || [null, null];
- return isWithinIndia(lat, lon);
+ // The India box also covers Pakistani Punjab and Nepal; "white villas"
+ // was answering from Lahore. Photon says the country outright.
+ const cc = item.properties?.countrycode;
+ return isWithinIndia(lat, lon) && (!cc || cc === 'IN');
})
.map((item) => {
const p = item.properties || {};
@@ -301,6 +336,9 @@ async function searchNominatim(query, { bias, limit = 8 } = {}) {
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 {
+ // A preference, not a fence (no `bounded`): southern matches rank first.
+ params.set('viewbox', `${SOUTH_BOUNDS.minLng},${SOUTH_BOUNDS.maxLat},${SOUTH_BOUNDS.maxLng},${SOUTH_BOUNDS.minLat}`);
}
const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
@@ -399,10 +437,107 @@ async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
}
}
+/**
+ * 4. India Post pincode directory
+ *
+ * OSM knows a pincode's rough centre but not the localities inside it, and
+ * small Indian localities are exactly what OSM is missing. India Post lists
+ * every post office under a pincode with its district and state, which is
+ * what a typist with only a pincode needs: pick the area, get city and state.
+ *
+ * These places carry NO coordinates on purpose. A post office name is an area,
+ * not a door; a centroid pin would be kilometres off and CreateOrder prices a
+ * drop from its pin. null is "no pin yet", which every caller already handles.
+ */
+const pincodeCache = new Map();
+
+export async function lookupPincode(pincode) {
+ const pin = String(pincode || '').trim();
+ if (!/^[1-9][0-9]{5}$/.test(pin)) return [];
+ if (pincodeCache.has(pin)) return pincodeCache.get(pin);
+ try {
+ const res = await fetchWithTimeout(`${INDIA_POST_PINCODE_URL}/${pin}`);
+ 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
+ });
+ });
+ pincodeCache.set(pin, places);
+ return places;
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Spelling variants for a South Indian locality name.
+ *
+ * OSM search has no typo tolerance for these, and they are spelt a dozen ways.
+ * The commonest slip is the nasal before the suffix: people type
+ * "manikarapalayam" for Maniyakaranpalayam / Maniyakaram Palayam, and only
+ * "manikaranpalayam" or "manikarampalayam" finds it. We have no paid geocoder
+ * (no Ola or Google key), so the search tries the few spellings a local would.
+ */
+// Tamil (-palayam, -pudur, -pakkam), Kannada (-halli, -palya) and Telugu
+// (-guda, -peta) endings. Longest first, so "palaiyam" wins over "palayam".
+const LOCALITY_SUFFIXES = [
+ 'palaiyam', 'palayam', 'puthur', 'pudur', 'puram', 'patti', 'patty', 'pettai', 'kottai', 'kulam', 'valasu',
+ 'kuppam', 'pakkam', 'bakkam', 'halli', 'hally', 'palya', 'palaya', 'nagar', 'guda', 'peta', 'pet'
+];
+const SUFFIX_SWAPS = {
+ palayam: 'palaiyam', palaiyam: 'palayam', patti: 'patty', patty: 'patti', pudur: 'puthur', puthur: 'pudur',
+ halli: 'hally', hally: 'halli', palya: 'palaya', palaya: 'palya', pakkam: 'bakkam', bakkam: 'pakkam',
+ pettai: 'pet', pet: 'pettai'
+};
+// The nasal slip ("manikarapalayam" for Maniyakaranpalayam) happens on Tamil endings.
+const NASAL_SUFFIXES = new Set(['palayam', 'palaiyam', 'pudur', 'puthur', 'puram', 'patti', 'pettai', 'kottai', 'kulam', 'pakkam', 'bakkam']);
+
+export function localitySpellings(word) {
+ const w = String(word || '').toLowerCase().replace(/[^a-z]/g, '');
+ const out = new Set();
+ const suffix = LOCALITY_SUFFIXES.find((sfx) => w.endsWith(sfx) && w.length > sfx.length + 2);
+ if (suffix) {
+ const stem = w.slice(0, -suffix.length);
+ if (NASAL_SUFFIXES.has(suffix) && /[aeiou]$/.test(stem)) {
+ out.add(`${stem}n${suffix}`);
+ out.add(`${stem}m${suffix}`);
+ } else if (NASAL_SUFFIXES.has(suffix) && /[mn]$/.test(stem)) {
+ out.add(`${stem.slice(0, -1)}${suffix}`);
+ out.add(`${stem.slice(0, -1)}${stem.endsWith('m') ? 'n' : 'm'}${suffix}`);
+ }
+ if (SUFFIX_SWAPS[suffix]) out.add(`${stem}${SUFFIX_SWAPS[suffix]}`);
+ }
+ // Long vowels are spelt both ways: "Peelamedu" / "Pilamedu", "Pooja" / "Puja".
+ const flat = w.replace(/aa/g, 'a').replace(/ee/g, 'i').replace(/oo/g, 'u');
+ if (flat.length > 3) out.add(flat);
+ out.delete(w);
+ return [...out].slice(0, 4);
+}
+
+// Variant searches are guesses; keep only what lands near the hub, or they
+// bring back same-named villages from the other end of the state.
+const VARIANT_RADIUS_DEG = 0.6; // ~65 km
+
/**
* Deduplicates results and ranks by active city or geographic proximity
*/
-function deduplicateAndRank(places, bias, limit = 8) {
+function deduplicateAndRank(places, bias, limit = 8, pin = '') {
const seen = new Set();
const unique = [];
@@ -417,25 +552,65 @@ function deduplicateAndRank(places, bias, limit = 8) {
unique.push(p);
}
- // Sort by bias city or proximity
- if (bias?.city) {
- const biasCity = bias.city.toLowerCase();
- unique.sort((a, b) => {
- const aMatches = (a.city || a.formatted_address || '').toLowerCase().includes(biasCity) ? 0 : 1;
- const bMatches = (b.city || b.formatted_address || '').toLowerCase().includes(biasCity) ? 0 : 1;
- return aMatches - bMatches;
- });
- } else if (bias?.lat && bias?.lng) {
- const bLat = Number(bias.lat);
- const bLng = Number(bias.lng);
- unique.sort((a, b) => {
- const distA = a.latitude && a.longitude ? Math.hypot(a.latitude - bLat, a.longitude - bLng) : 999;
- const distB = b.latitude && b.longitude ? Math.hypot(b.latitude - bLat, b.longitude - bLng) : 999;
- return distA - distB;
- });
- }
+ // 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.
+ 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 score = (p) => [
+ p.provider === 'recent' ? 0 : 1,
+ pin && String(p.postcode) !== pin ? 1 : 0,
+ isInSouth(p) ? 0 : 1,
+ 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
+ ];
+ 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);
+}
- return unique.slice(0, limit);
+/** Round-robin merge, so each city's best match shows before any city's fifth. */
+const interleave = (lists) => {
+ const out = [];
+ const longest = Math.max(0, ...lists.map((l) => l.length));
+ for (let i = 0; i < longest; i += 1) lists.forEach((l) => l[i] && out.push(l[i]));
+ return out;
+};
+
+/**
+ * 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.
+ */
+async function searchPhotonSouth(query, options = {}) {
+ const hubLat = Number(options.bias?.lat);
+ const hubLng = Number(options.bias?.lng);
+ const hasHub = Boolean(hubLat && hubLng);
+ 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 })),
+ searchPhoton(query, { ...options, bias: undefined, bbox: SOUTH_BBOX_PHOTON, limit: perCity })
+ ]);
+ return interleave(lists);
+}
+
+/** Addresses this browser picked before whose text contains the query. */
+function matchRecent(query, max = 3) {
+ const q = query.toLowerCase();
+ return getRecentAddresses(10)
+ .filter((r) => (r.formatted_address || '').toLowerCase().includes(q))
+ .slice(0, max)
+ .map((r) => standardizePlace({ ...r, provider: 'recent' }));
}
/**
@@ -454,24 +629,77 @@ export async function getAddressSuggestions(query, options = {}) {
if (cleanQuery.length < 2) return [];
- // Parallel Execution
+ const limit = options.limit || 12;
+ const pinMatch = cleanQuery.match(/(?:^|\D)([1-9][0-9]{5})(?:\D|$)/);
+ const pinOnly = /^[1-9][0-9]{5}$/.test(cleanQuery);
+ const collect = (settled) =>
+ settled.flatMap((res) => (res.status === 'fulfilled' && Array.isArray(res.value) ? res.value : []));
+
+ // 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);
+ }
+
const promises = [];
if (OLA_MAPS_KEY) {
promises.push(searchOlaMaps(cleanQuery, options));
}
- promises.push(searchPhoton(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]));
- const results = await Promise.allSettled(promises);
- const places = [];
+ 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);
+ }
- for (const res of results) {
- if (res.status === 'fulfilled' && Array.isArray(res.value)) {
- places.push(...res.value);
+ // 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))];
}
}
- return deduplicateAndRank(places, options.bias, options.limit || 8);
+ return deduplicateAndRank(places, options.bias, limit, pinMatch ? pinMatch[1] : '');
}
/**
diff --git a/src/pages/doormile/clients/CreateCustomer.jsx b/src/pages/doormile/clients/CreateCustomer.jsx
index 50ba03c..e66ca87 100644
--- a/src/pages/doormile/clients/CreateCustomer.jsx
+++ b/src/pages/doormile/clients/CreateCustomer.jsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, MapPin } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -12,6 +12,8 @@ import { OpenToast } from '@/components/third-party/OpenToast';
import { createTenantCustomer } from '@/api/doormile/endpoints';
import { friendlyErrorMessage } from '@/api/doormile/client';
import { useTenantCustomers } from '@/lib/doormileHooks';
+import { useZone } from '@/lib/ZoneContext';
+import { lookupPincode } from '@/lib/geocodingService';
/**
* Create a customer.
@@ -101,6 +103,21 @@ export default function CreateCustomer() {
const [form, setForm] = useState(EMPTY_FORM);
const [errors, setErrors] = useState({});
const [isMapOpen, setIsMapOpen] = useState(false);
+ // Areas under the typed pincode, for picking the suburb when it has several.
+ const [pinAreas, setPinAreas] = useState({ pin: '', areas: [], loading: false });
+
+ // Rank suggestions near the hub the operator is working in. With no bias the
+ // search spans all of India, and "white villas …" answers from Bangalore.
+ const { selectedZone, isAllZones } = useZone();
+ const bias = useMemo(() => {
+ if (isAllZones || !selectedZone) return undefined;
+ const lat = Number(selectedZone.latitude);
+ const lng = Number(selectedZone.longitude);
+ return {
+ ...(Number.isFinite(lat) && Number.isFinite(lng) && lat && lng ? { lat, lng } : {}),
+ city: selectedZone.city || '',
+ };
+ }, [selectedZone, isAllZones]);
const set = (key) => (value) => {
setForm((current) => ({ ...current, [key]: value }));
@@ -114,9 +131,38 @@ export default function CreateCustomer() {
setForm((current) => ({
...current,
...Object.fromEntries(Object.entries(next).filter(([, v]) => v !== '')),
+ // A pincode area has no map point. Keeping the previous pin would file
+ // this customer at wherever the last pick was.
+ ...(place.provider === 'indiapost' ? { latitude: '', longitude: '' } : {}),
}));
};
+ // Pincode alone is enough to fill city and state, and the suburb too when
+ // the pincode covers a single area. Fields already typed are left alone.
+ useEffect(() => {
+ const pin = form.postcode;
+ if (!/^[1-9]\d{5}$/.test(pin)) {
+ setPinAreas({ pin: '', areas: [], loading: false });
+ return undefined;
+ }
+ let cancelled = false;
+ setPinAreas({ pin, areas: [], loading: true });
+ lookupPincode(pin).then((areas) => {
+ if (cancelled) return;
+ setPinAreas({ pin, areas, loading: false });
+ if (!areas.length) return;
+ setForm((current) => ({
+ ...current,
+ city: current.city || areas[0].city,
+ state: current.state || areas[0].state,
+ suburb: current.suburb || (areas.length === 1 ? areas[0].suburb : ''),
+ }));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [form.postcode]);
+
// Warn — don't block — when the number is already on file. The same person
// can legitimately be a customer of two clients.
const duplicate = useMemo(() => {
@@ -245,7 +291,7 @@ export default function CreateCustomer() {
@@ -255,6 +301,7 @@ export default function CreateCustomer() {
label="Address"
placeholder="Search area, street, society, pincode or paste a map link…"
value={form.address}
+ bias={bias}
onChange={(text) => set('address')(text)}
onPlaceSelected={applyPlace}
onOpenMapPin={() => setIsMapOpen(true)}
@@ -298,7 +345,42 @@ export default function CreateCustomer() {
placeholder="641004"
inputMode="numeric"
/>
+ {pinAreas.pin === form.postcode && pinAreas.loading && (
+
Looking up {form.postcode}…
+ )}
+ {pinAreas.pin === form.postcode && !pinAreas.loading && !pinAreas.areas.length && (
+
+ We couldn’t find this pincode. Check it, or fill city and state by hand.
+
+ )}
+ {pinAreas.pin === form.postcode && pinAreas.areas.length > 1 && (
+
+
+ Areas under {form.postcode}: pick one to set the suburb
+
+
+ {pinAreas.areas.map((area) => {
+ const active = form.suburb.toLowerCase() === area.suburb.toLowerCase();
+ return (
+
+ );
+ })}
+
+
+ )}
diff --git a/src/pages/doormile/home/Home.jsx b/src/pages/doormile/home/Home.jsx
index 28051c6..493c6e6 100644
--- a/src/pages/doormile/home/Home.jsx
+++ b/src/pages/doormile/home/Home.jsx
@@ -2247,21 +2247,23 @@ export default function Home() {
{/* BOTTOM APP LAUNCHER & SUITES */}