updates on the addressautocomplete and geocodingservice and home page as been updated

This commit is contained in:
2026-09-28 19:08:17 +05:30
parent 5bf0104b61
commit 05a4cbdf8a
4 changed files with 427 additions and 66 deletions

View File

@@ -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 = ({
</div>
</div>
{isOpen && noResults && options.length === 0 && !loading && (
<div
id={listId}
role="status"
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl px-3.5 py-3 text-[11px] text-slate-600"
>
<div className="font-semibold text-slate-800">No matching address found</div>
<div className="mt-0.5">
Try just the area name or the 6-digit pincode
{onOpenMapPin ? (
<>
, or{' '}
<button
type="button"
onClick={() => {
setIsOpen(false);
onOpenMapPin();
}}
className="font-semibold text-blue-600 hover:underline cursor-pointer"
>
drop a pin on the map
</button>
</>
) : null}
.
</div>
</div>
)}
{isOpen && options.length > 0 && (
<div
id={listId}
@@ -327,7 +364,8 @@ const AddressAutocomplete = ({
<ul className="max-h-72 overflow-y-auto divide-y divide-slate-100">
{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 (
<li
@@ -353,6 +391,12 @@ const AddressAutocomplete = ({
{mainTitle}
</span>
{option.provider === 'recent' && !isRecentMode && (
<span className="text-[10px] font-semibold px-1.5 py-0.2 rounded bg-sky-50 text-sky-700 border border-sky-100 shrink-0">
Recent
</span>
)}
{option.suburb && option.suburb !== mainTitle && (
<span className="text-[10px] font-medium px-1.5 py-0.2 rounded bg-violet-50 text-violet-700 border border-violet-100 shrink-0">
{option.suburb}
@@ -374,6 +418,11 @@ const AddressAutocomplete = ({
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5">
{option.formatted_address}
</div>
{areaOnly && (
<div className="text-[10px] text-amber-700 mt-0.5">
Pincode area. Fills city and state; pin the exact spot on the map.
</div>
)}
</div>
</button>
</li>

View File

@@ -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] : '');
}
/**

View File

@@ -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() {
<Surface radius="xl" padding="lg">
<SectionHeader
title="Where"
subtitle="Search for the address to fill the city, pincode and map location automatically, or drop a pin on the map."
subtitle="Search for the address to fill the city, pincode and map location automatically. Only have the pincode? Type it in the Pincode field to fill city, state and area. Drop a pin on the map for the exact spot."
className="mb-4"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -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 && (
<p className="mt-1 text-[11px] text-slate-500">Looking up {form.postcode}…</p>
)}
{pinAreas.pin === form.postcode && !pinAreas.loading && !pinAreas.areas.length && (
<p className="mt-1 text-[11px] text-amber-700">
We couldn’t find this pincode. Check it, or fill city and state by hand.
</p>
)}
</Field>
{pinAreas.pin === form.postcode && pinAreas.areas.length > 1 && (
<div className="sm:col-span-2 lg:col-span-3">
<div className="text-[11px] font-medium text-slate-500 mb-1.5">
Areas under {form.postcode}: pick one to set the suburb
</div>
<div className="flex flex-wrap gap-1.5">
{pinAreas.areas.map((area) => {
const active = form.suburb.toLowerCase() === area.suburb.toLowerCase();
return (
<button
key={area.suburb}
type="button"
aria-pressed={active}
onClick={() => set('suburb')(area.suburb)}
className={
active
? 'rounded-full border border-brand bg-brand/10 px-2.5 py-1 text-[11px] font-semibold text-brand'
: 'rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-medium text-slate-700 hover:border-slate-300 hover:bg-slate-50'
}
>
{area.suburb}
</button>
);
})}
</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">

View File

@@ -2247,21 +2247,23 @@ export default function Home() {
{/* BOTTOM APP LAUNCHER & SUITES */}
<footer className="w-full pt-4 pb-2 border-t border-slate-100">
{/* Tiles wrap inside each group: on a phone a group column is ~160px and
three 72px tiles didn't fit, so the row ran past the screen and
the whole page scrolled sideways. */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8 max-w-5xl mx-auto">
{/* Tiles are a fixed w-16 (the old w-18 is not a Tailwind 3 class, so
each tile sized to its label and "Milers Summary" pushed Exceptions
onto a row of its own). A group of three needs 208px, so groups
stack below sm, go two-up until lg, and only go four-up once each
column is wide enough to hold three tiles. */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-5 max-w-5xl mx-auto">
{/* 1. OPERATIONS */}
<div className="flex flex-col items-start">
<div className="text-[11px] font-bold uppercase tracking-wider text-slate-400 mb-3 px-1">
OPERATIONS
</div>
<div className="flex flex-wrap items-start gap-2.5 sm:gap-3">
<div className="flex flex-wrap items-start gap-x-2 gap-y-3">
{/* Orders Tile */}
<Link
to="/doormile/orders"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="relative w-12 h-12 rounded-2xl bg-orange-50 border border-orange-200/90 flex items-center justify-center text-orange-500 group-hover:scale-105 group-hover:bg-orange-100 group-hover:shadow-sm transition-all duration-200">
{/* The red "3" that sat here was a literal, not a count of
@@ -2271,7 +2273,7 @@ export default function Home() {
that will eventually be real. */}
<Package className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Orders
</span>
</Link>
@@ -2280,12 +2282,12 @@ export default function Home() {
still holds: the label names the page it opens. */}
<Link
to="/doormile/control-x"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="w-12 h-12 rounded-2xl bg-sky-50 border border-sky-200/90 flex items-center justify-center text-sky-500 group-hover:scale-105 group-hover:bg-sky-100 group-hover:shadow-sm transition-all duration-200">
<Clock className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Control X
</span>
</Link>
@@ -2298,12 +2300,12 @@ export default function Home() {
operator a click to find that out, every time. */}
<Link
to="/doormile/tripsheets"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="w-12 h-12 rounded-2xl bg-purple-50 border border-purple-200/90 flex items-center justify-center text-purple-500 group-hover:scale-105 group-hover:bg-purple-100 group-hover:shadow-sm transition-all duration-200">
<ListTodo className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Tripsheets
</span>
</Link>
@@ -2317,16 +2319,16 @@ export default function Home() {
<div className="text-[11px] font-bold uppercase tracking-wider text-slate-400 mb-3 px-1">
FLEET
</div>
<div className="flex flex-wrap items-start gap-2.5 sm:gap-3">
<div className="flex flex-wrap items-start gap-x-2 gap-y-3">
{/* Staff Directory */}
<Link
to="/doormile/milers"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="w-12 h-12 rounded-2xl bg-emerald-50 border border-emerald-200/90 flex items-center justify-center text-emerald-500 group-hover:scale-105 group-hover:bg-emerald-100 group-hover:shadow-sm transition-all duration-200">
<Users className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Milers
</span>
</Link>
@@ -2344,12 +2346,12 @@ export default function Home() {
a number nobody measured is worse than no number. */}
<Link
to="/doormile/reports/riderssummary"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="relative w-12 h-12 rounded-2xl bg-cyan-50 border border-cyan-200/90 flex items-center justify-center text-cyan-500 group-hover:scale-105 group-hover:bg-cyan-100 group-hover:shadow-sm transition-all duration-200">
<Calendar className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Milers Summary
</span>
</Link>
@@ -2366,12 +2368,12 @@ export default function Home() {
teaches operators to ignore the real one. */}
<Link
to="/doormile/exceptions"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="relative w-12 h-12 rounded-2xl bg-amber-50 border border-amber-200/90 flex items-center justify-center text-amber-500 group-hover:scale-105 group-hover:bg-amber-100 group-hover:shadow-sm transition-all duration-200">
<Shield className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Exceptions
</span>
</Link>
@@ -2386,19 +2388,19 @@ export default function Home() {
<div className="text-[11px] font-bold uppercase tracking-wider text-slate-400 mb-3 px-1">
CLIENTS &amp; REVENUE
</div>
<div className="flex flex-wrap items-start gap-2.5 sm:gap-3">
<div className="flex flex-wrap items-start gap-x-2 gap-y-3">
{/* Bookings.
Labelled "Invoices", which this console does not have — the
page is the customer-app booking list. Billing lives on the
Profitability report, not here. */}
<Link
to="/doormile/bookings"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="relative w-12 h-12 rounded-2xl bg-blue-50 border border-blue-200/90 flex items-center justify-center text-blue-500 group-hover:scale-105 group-hover:bg-blue-100 group-hover:shadow-sm transition-all duration-200">
<FileText className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Bookings
</span>
</Link>
@@ -2411,12 +2413,12 @@ export default function Home() {
than one that says what it is. */}
<Link
to="/doormile/reports/profitability"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="w-12 h-12 rounded-2xl bg-emerald-50 border border-emerald-200/90 flex items-center justify-center text-emerald-500 group-hover:scale-105 group-hover:bg-emerald-100 group-hover:shadow-sm transition-all duration-200">
<Coins className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Profitability
</span>
</Link>
@@ -2424,12 +2426,12 @@ export default function Home() {
{/* Clients */}
<Link
to="/doormile/tenants"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="w-12 h-12 rounded-2xl bg-purple-50 border border-purple-200/90 flex items-center justify-center text-purple-500 group-hover:scale-105 group-hover:bg-purple-100 group-hover:shadow-sm transition-all duration-200">
<Building2 className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Clients
</span>
</Link>
@@ -2445,7 +2447,7 @@ export default function Home() {
<div className="text-[11px] font-bold uppercase tracking-wider text-slate-400 mb-3 px-1">
REPORTS &amp; AI
</div>
<div className="flex flex-wrap items-start gap-2.5 sm:gap-3">
<div className="flex flex-wrap items-start gap-x-2 gap-y-3">
{/* Orders Summary.
Called "AI Reports", but nothing about the page it opens is
AI — it is the daily volume and status aggregation, computed
@@ -2454,12 +2456,12 @@ export default function Home() {
parts of this console that really are model-driven. */}
<Link
to="/doormile/reports/orderssummary"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
>
<div className="w-12 h-12 rounded-2xl bg-teal-50 border border-teal-200/90 flex items-center justify-center text-teal-500 group-hover:scale-105 group-hover:bg-teal-100 group-hover:shadow-sm transition-all duration-200">
<FileSpreadsheet className="w-5 h-5" />
</div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
Orders Summary
</span>
</Link>