updates on the addressautocomplete and geocodingservice and home page as been updated
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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] : '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user