From 05a4cbdf8a92b138d7b58f7bab6544d77f4fcbf6 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Mon, 28 Sep 2026 19:08:17 +0530 Subject: [PATCH] updates on the addressautocomplete and geocodingservice and home page as been updated --- .../doormile/AddressAutocomplete.jsx | 59 +++- src/lib/geocodingService.js | 290 ++++++++++++++++-- src/pages/doormile/clients/CreateCustomer.jsx | 86 +++++- src/pages/doormile/home/Home.jsx | 58 ++-- 4 files changed, 427 insertions(+), 66 deletions(-) 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 */}