updates on the autocomplete in the create order page

This commit is contained in:
2026-09-11 15:39:35 +05:30
parent 49ee0c5076
commit bc79ba4912
4 changed files with 719 additions and 240 deletions

View File

@@ -1,7 +1,16 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react'; import { Clock, Compass, Crosshair, Loader2, MapPin, Navigation, X } from 'lucide-react';
import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService'; import {
extractLandmark,
getAddressSuggestions,
geocodeAddress as serviceGeocodeAddress,
getRecentAddresses,
reverseGeocode,
saveRecentAddress,
standardizePlace
} from '@/lib/geocodingService';
import { OpenToast } from '@/components/third-party/OpenToast';
export const toPlace = (result) => standardizePlace(result); export const toPlace = (result) => standardizePlace(result);
@@ -25,11 +34,12 @@ const useDebouncedCallback = (callback, delay) => {
const AddressAutocomplete = ({ const AddressAutocomplete = ({
id, id,
label, label,
placeholder = 'Search address or locality…', placeholder = 'Search area, street, society, pincode or paste link…',
value, value,
onChange, onChange,
onPlaceSelected, onPlaceSelected,
onOpenMapPin, onOpenMapPin,
onLandmarkExtracted,
bias, bias,
fullWidth = true, fullWidth = true,
disabled = false, disabled = false,
@@ -40,8 +50,10 @@ const AddressAutocomplete = ({
const [inputValue, setInputValue] = useState(value || ''); const [inputValue, setInputValue] = useState(value || '');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isLocating, setIsLocating] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeOption, setActiveOption] = useState(-1); const [activeOption, setActiveOption] = useState(-1);
const [isRecentMode, setIsRecentMode] = useState(false);
const wrapRef = useRef(null); const wrapRef = useRef(null);
const activeRef = useRef(0); const activeRef = useRef(0);
@@ -55,36 +67,59 @@ const AddressAutocomplete = ({
} }
}, [value]); }, [value]);
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`; const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''},${bias?.city ?? ''}`;
const fetchPredictions = useDebouncedCallback((query) => { const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current; const seq = ++activeRef.current;
getAddressSuggestions(query, { bias, limit: 6 }) getAddressSuggestions(query, { bias, limit: 8 })
.then((results) => { .then((results) => {
if (seq !== activeRef.current) return; if (seq !== activeRef.current) return;
setLoading(false); setLoading(false);
setOptions(results || []); setOptions(results || []);
setIsRecentMode(false);
setIsOpen((results || []).length > 0); setIsOpen((results || []).length > 0);
}) })
.catch(() => { .catch(() => {
if (seq !== activeRef.current) return; if (seq !== activeRef.current) return;
setLoading(false); setLoading(false);
setOptions([]); setOptions([]);
setIsOpen(false);
}); });
}, 220); }, 200);
// Handle Focus: Show Recent Addresses if field is empty
const handleFocus = () => {
if (disabled) return;
if (!inputValue || inputValue.trim().length === 0) {
const recents = getRecentAddresses(5);
if (recents.length > 0) {
setOptions(recents);
setIsRecentMode(true);
setIsOpen(true);
}
}
};
// Trigger search when input changes
useEffect(() => { useEffect(() => {
if (skipNextSearchRef.current) { if (skipNextSearchRef.current) {
skipNextSearchRef.current = false; skipNextSearchRef.current = false;
return; return;
} }
if (!inputValue || inputValue.length < 2) { if (!inputValue || inputValue.trim().length < 2) {
activeRef.current++; activeRef.current++;
setOptions([]); const recents = getRecentAddresses(5);
if (recents.length > 0 && (!inputValue || inputValue.length === 0)) {
setOptions(recents);
setIsRecentMode(true);
} else {
setOptions([]);
setIsOpen(false);
}
setLoading(false); setLoading(false);
setIsOpen(false);
return; return;
} }
setLoading(true); setLoading(true);
fetchPredictions(inputValue); fetchPredictions(inputValue);
}, [inputValue, biasKey]); }, [inputValue, biasKey]);
@@ -108,12 +143,53 @@ const AddressAutocomplete = ({
skipNextSearchRef.current = true; skipNextSearchRef.current = true;
activeRef.current++; activeRef.current++;
setInputValue(formatted); setInputValue(formatted);
saveRecentAddress(option);
onChange?.(formatted); onChange?.(formatted);
onPlaceSelected?.(option); onPlaceSelected?.(option);
// Extract landmark if present
const extractedLmk = extractLandmark(formatted);
if (extractedLmk && onLandmarkExtracted) {
onLandmarkExtracted(extractedLmk);
}
setIsOpen(false); setIsOpen(false);
setActiveOption(-1); setActiveOption(-1);
}; };
// Browser Geolocation Quick-Find
const handleUseCurrentLocation = () => {
if (!navigator.geolocation) {
OpenToast('Geolocation is not supported by your browser', 'warning');
return;
}
setIsLocating(true);
navigator.geolocation.getCurrentPosition(
async (pos) => {
try {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
const place = await reverseGeocode(lat, lng);
if (place) {
pick(place);
OpenToast('Detected current location', 'info', 2000);
} else {
OpenToast('Could not resolve address for current location', 'warning');
}
} catch (err) {
OpenToast('Location resolution failed', 'error');
} finally {
setIsLocating(false);
}
},
(err) => {
setIsLocating(false);
OpenToast('Please allow location permission in your browser', 'warning');
},
{ timeout: 10000, enableHighAccuracy: true }
);
};
const onKeyDown = (e) => { const onKeyDown = (e) => {
const listOpen = isOpen && options.length > 0; const listOpen = isOpen && options.length > 0;
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -142,17 +218,34 @@ const AddressAutocomplete = ({
{label} {required && <span className="text-red-500">*</span>} {label} {required && <span className="text-red-500">*</span>}
</label> </label>
)} )}
{onOpenMapPin && ( <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={onOpenMapPin} onClick={handleUseCurrentLocation}
className="text-[11px] font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1 cursor-pointer transition-colors" disabled={isLocating}
title="Choose point on map" className="text-[11px] font-semibold text-slate-600 hover:text-black flex items-center gap-1 cursor-pointer transition-colors"
title="Use current GPS location"
> >
<Compass className="w-3 h-3" /> {isLocating ? (
<span>Pin on Map</span> <Loader2 className="w-3 h-3 animate-spin text-slate-600" />
) : (
<Crosshair className="w-3 h-3 text-slate-500" />
)}
<span>Current GPS</span>
</button> </button>
)}
{onOpenMapPin && (
<button
type="button"
onClick={onOpenMapPin}
className="text-[11px] font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1 cursor-pointer transition-colors"
title="Choose point on map"
>
<Compass className="w-3 h-3" />
<span>Pin on Map</span>
</button>
)}
</div>
</div> </div>
<div className="relative"> <div className="relative">
@@ -167,6 +260,7 @@ const AddressAutocomplete = ({
aria-controls={listId} aria-controls={listId}
aria-autocomplete="list" aria-autocomplete="list"
aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined} aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined}
onFocus={handleFocus}
onChange={(e) => { onChange={(e) => {
setInputValue(e.target.value); setInputValue(e.target.value);
onChange?.(e.target.value); onChange?.(e.target.value);
@@ -180,7 +274,7 @@ const AddressAutocomplete = ({
{/* Action icons on the right */} {/* Action icons on the right */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{loading ? ( {loading || isLocating ? (
<span className="text-slate-400 p-0.5"> <span className="text-slate-400 p-0.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
</span> </span>
@@ -190,6 +284,7 @@ const AddressAutocomplete = ({
onClick={() => { onClick={() => {
setInputValue(''); setInputValue('');
onChange?.(''); onChange?.('');
setOptions([]);
setIsOpen(false); setIsOpen(false);
}} }}
className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 cursor-pointer" className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 cursor-pointer"
@@ -213,57 +308,79 @@ const AddressAutocomplete = ({
</div> </div>
{isOpen && options.length > 0 && ( {isOpen && options.length > 0 && (
<ul <div
id={listId} id={listId}
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100" className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-100 divide-y divide-slate-100"
role="listbox" role="listbox"
aria-label="Address suggestions" aria-label="Address suggestions"
> >
{options.map((option, index) => { {isRecentMode && (
const isHighlight = index === activeOption; <div className="px-3 py-1.5 bg-slate-50 border-b border-slate-100 flex items-center justify-between text-[11px] font-semibold text-slate-600">
return ( <span className="flex items-center gap-1.5">
<li <Clock className="w-3 h-3 text-slate-500" />
key={`${option.latitude}-${option.longitude}-${index}`} <span>Recent Deliveries</span>
id={optionId(index)} </span>
role="option" <span className="text-[10px] text-slate-400 font-normal">Click to reuse</span>
aria-selected={isHighlight} </div>
className={isHighlight ? 'bg-slate-50' : undefined} )}
>
<button <ul className="max-h-72 overflow-y-auto divide-y divide-slate-100">
type="button" {options.map((option, index) => {
tabIndex={-1} const isHighlight = index === activeOption;
onMouseEnter={() => setActiveOption(index)} const mainTitle = option.name || option.formatted_address.split(',')[0];
onClick={() => pick(option)}
className="w-full text-left px-3.5 py-2.5 hover:bg-slate-50/80 flex items-start gap-2.5 transition-colors cursor-pointer group" return (
<li
key={`${option.latitude}-${option.longitude}-${index}`}
id={optionId(index)}
role="option"
aria-selected={isHighlight}
className={isHighlight ? 'bg-slate-50' : undefined}
> >
<div className="w-6 h-6 rounded-md bg-slate-100 group-hover:bg-black text-slate-500 group-hover:text-white flex items-center justify-center shrink-0 mt-0.5 transition-colors"> <button
<MapPin className="w-3.5 h-3.5" /> type="button"
</div> tabIndex={-1}
<div className="min-w-0 flex-1"> onMouseEnter={() => setActiveOption(index)}
<div className="flex items-center gap-2"> onClick={() => pick(option)}
<span className="text-xs font-semibold text-slate-900 truncate"> className="w-full text-left px-3.5 py-2.5 hover:bg-slate-50/80 flex items-start gap-2.5 transition-colors cursor-pointer group"
{option.name || option.formatted_address.split(',')[0]} >
</span> <div className="w-6 h-6 rounded-md bg-slate-100 group-hover:bg-black text-slate-500 group-hover:text-white flex items-center justify-center shrink-0 mt-0.5 transition-colors">
{option.suburb && ( <MapPin className="w-3.5 h-3.5" />
<span className="text-[10px] font-medium px-1.5 py-0.2 rounded bg-slate-100 text-slate-600 shrink-0">
{option.suburb}
</span>
)}
{option.postcode && (
<span className="text-[10px] font-mono text-slate-400 shrink-0">
{option.postcode}
</span>
)}
</div> </div>
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5"> <div className="min-w-0 flex-1">
{option.formatted_address} <div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-bold text-slate-900 truncate">
{mainTitle}
</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}
</span>
)}
{option.city && (
<span className="text-[10px] font-semibold px-1.5 py-0.2 rounded bg-slate-100 text-slate-700 shrink-0">
{option.city}
</span>
)}
{option.postcode && (
<span className="text-[10px] font-mono font-medium px-1 py-0.2 rounded bg-emerald-50 text-emerald-700 border border-emerald-100 shrink-0">
{option.postcode}
</span>
)}
</div>
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5">
{option.formatted_address}
</div>
</div> </div>
</div> </button>
</button> </li>
</li> );
); })}
})} </ul>
</ul> </div>
)} )}
</div> </div>
); );
@@ -277,6 +394,7 @@ AddressAutocomplete.propTypes = {
onChange: PropTypes.func, onChange: PropTypes.func,
onPlaceSelected: PropTypes.func, onPlaceSelected: PropTypes.func,
onOpenMapPin: PropTypes.func, onOpenMapPin: PropTypes.func,
onLandmarkExtracted: PropTypes.func,
bias: PropTypes.object, bias: PropTypes.object,
fullWidth: PropTypes.bool, fullWidth: PropTypes.bool,
disabled: PropTypes.bool, disabled: PropTypes.bool,

View File

@@ -1,50 +1,58 @@
/** /**
* Calculates distance (in km) between origin and destination. * Driving Distance, Duration & Route Polyline Calculator for DoorMile
* Uses OSRM as primary driving distance source, and falls back to Haversine with a 1.3 multiplier. * Uses OSRM as primary routing engine with GeoJSON polyline extraction,
* * and falls back seamlessly to Haversine with a 1.3 road winding multiplier.
* @param {Object} origin - { latitude, longitude }
* @param {Object} destination - { latitude, longitude }
* @returns {Promise<number>} - Round distance in KM
*/ */
// Duration from the most recent OSRM route, in minutes, or null when the
// Haversine fallback was used (a straight-line estimate has no honest
// duration — better to show nothing than a made-up ETA).
let lastRouteDurationMin = null; let lastRouteDurationMin = null;
export const getLastRouteDurationMin = () => lastRouteDurationMin; export const getLastRouteDurationMin = () => lastRouteDurationMin;
export const calculateDrivingDistance = async (origin, destination) => { /**
const lat1 = origin?.latitude; * Calculates complete driving route with distance, duration, and road polyline
const lon1 = origin?.longitude; * @param {Object} origin - { latitude, longitude }
const lat2 = destination?.latitude; * @param {Object} destination - { latitude, longitude }
const lon2 = destination?.longitude; * @returns {Promise<{ distance: number, minutes: number, polyline: Array<[number, number]>, resolved: boolean }>}
*/
export const calculateDrivingRoute = async (origin, destination) => {
const lat1 = Number(origin?.latitude);
const lon1 = Number(origin?.longitude);
const lat2 = Number(destination?.latitude);
const lon2 = Number(destination?.longitude);
if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) { if (!Number.isFinite(lat1) || !Number.isFinite(lon1) || !Number.isFinite(lat2) || !Number.isFinite(lon2)) {
throw new Error("Invalid coordinates"); throw new Error("Invalid coordinates");
} }
// 1. Try OSRM API (either self-hosted or public demo server for fallback) // 1. Try OSRM API with full GeoJSON geometry for live polyline map preview
const osrmBaseUrl = import.meta.env.VITE_OSRM_URL || "https://router.project-osrm.org"; const osrmBaseUrl =
(typeof import.meta !== 'undefined' && import.meta?.env?.VITE_OSRM_URL) ||
'https://router.project-osrm.org';
try { try {
const url = `${osrmBaseUrl}/route/v1/driving/${lon1},${lat1};${lon2},${lat2}?overview=false`; const url = `${osrmBaseUrl}/route/v1/driving/${lon1},${lat1};${lon2},${lat2}?overview=full&geometries=geojson`;
const response = await fetch(url); const response = await fetch(url);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.routes && data.routes.length > 0) { if (data.routes && data.routes.length > 0) {
const distanceInMeters = data.routes[0].distance; const route = data.routes[0];
const distanceInKm = distanceInMeters / 1000; const distanceKm = Math.round(route.distance / 1000);
// OSRM already returns the driving duration alongside the distance; const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null;
// it was being discarded. Cached here so callers that want an ETA get // OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng]
// a REAL routed figure instead of dividing km by a guessed speed. const polyline = (route.geometry?.coordinates || []).map(([lng, lat]) => [lat, lng]);
lastRouteDurationMin = Number.isFinite(data.routes[0].duration) ? Math.round(data.routes[0].duration / 60) : null; lastRouteDurationMin = durationMin;
return Math.round(distanceInKm); return {
distance: Math.max(0, distanceKm),
minutes: durationMin,
polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]],
resolved: true
};
} }
} }
} catch (error) { } catch (error) {
console.warn("OSRM API failed, falling back to Haversine math:", error); console.warn("OSRM API failed, falling back to Haversine math:", error);
} }
// 2. Fallback: Haversine Formula with 1.3x multiplier // 2. Fallback: Haversine Formula with 1.3x road factor
const toRad = (val) => (val * Math.PI) / 180; const toRad = (val) => (val * Math.PI) / 180;
const R = 6371; // Earth radius in km const R = 6371; // Earth radius in km
const dLat = toRad(lat2 - lat1); const dLat = toRad(lat2 - lat1);
@@ -54,25 +62,36 @@ export const calculateDrivingDistance = async (origin, destination) => {
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2); Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const aerialDistance = R * c; const aerialDistance = Math.round(R * c * 1.3);
// No route was fetched, so there is no honest duration to report.
lastRouteDurationMin = null; lastRouteDurationMin = null;
return Math.round(aerialDistance * 1.3); return {
distance: aerialDistance,
minutes: null,
polyline: [[lat1, lon1], [lat2, lon2]],
resolved: true
};
};
/**
* Calculates distance (in km) between origin and destination
*/
export const calculateDrivingDistance = async (origin, destination) => {
const res = await calculateDrivingRoute(origin, destination);
return res.distance;
}; };
/** /**
* Calculates total charge based on distance and pricing tier * Calculates total charge based on distance and pricing tier
*
* @param {number} distanceKm
* @param {number} basePrice
* @param {number} pricePerKm
* @param {number} minKm
* @returns {number}
*/ */
export const calculateTotalCharge = (distanceKm, basePrice, pricePerKm, minKm) => { export const calculateTotalCharge = (distanceKm, basePrice, pricePerKm, minKm) => {
if (distanceKm < minKm) { const dist = Number(distanceKm) || 0;
return basePrice; const base = Number(basePrice) || 40;
const perKm = Number(pricePerKm) || 10;
const min = Number(minKm) || 2;
if (dist <= min) {
return base;
} }
return (distanceKm - minKm) * pricePerKm + basePrice; return (dist - min) * perKm + base;
}; };

View File

@@ -1,11 +1,14 @@
/** /**
* Enterprise Geocoding & Address Autocomplete Service * Enterprise Dynamic Geocoding & Address Autocomplete Service for DoorMile
* *
* Supports multi-provider hierarchy: * 100% Dynamic, Functional Geocoding Pipeline:
* 1. Ola Maps Places Autocomplete (if VITE_OLA_MAPS_API_KEY is configured) * 1. Door / Flat / Plot Prefix Parser
* 2. Google Places API (if VITE_GOOGLE_MAPS_API_KEY is configured) * 2. Google Maps URL & Raw Coordinates Paste Parser
* 3. Photon (Komoot OSM - high-speed, typo-tolerant, sub-100ms autocomplete) * 3. 6-Digit Indian Pincode Auto-Resolver
* 4. Nominatim (OpenStreetMap standard geocoder with country lock) * 4. India-Scoped High-Speed Photon (OSM) with Bounding Box
* 5. India-Locked Nominatim Search (countrycodes=in)
* 6. Recent Addresses Local Storage Manager
* 7. Automatic Landmark Extractor
*/ */
const getEnvVar = (key) => { const getEnvVar = (key) => {
@@ -29,8 +32,92 @@ const PHOTON_REVERSE_URL = 'https://photon.komoot.io/reverse';
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse'; const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse';
// Geographic boundary of India (minLon, minLat, maxLon, maxLat)
const INDIA_BOUNDS = {
minLat: 6.5,
maxLat: 37.5,
minLng: 68.0,
maxLng: 97.5
};
const INDIA_BBOX_PHOTON = '68.0,6.5,97.5,37.5';
const RECENT_ADDRESSES_STORAGE_KEY = 'doormile_recent_addresses';
/** /**
* Standardize place result into unified format for Doormile order forms * Strict boundary validator ensuring suggestions stay inside India
*/
export const isWithinIndia = (lat, lon) => {
const nLat = Number(lat);
const nLon = Number(lon);
if (!Number.isFinite(nLat) || !Number.isFinite(nLon)) return false;
return (
nLat >= INDIA_BOUNDS.minLat &&
nLat <= INDIA_BOUNDS.maxLat &&
nLon >= INDIA_BOUNDS.minLng &&
nLon <= INDIA_BOUNDS.maxLng
);
};
/**
* Extracts door / flat / plot / house number prefix
*/
export const extractDoorPrefix = (text) => {
if (!text) return { doorNo: '', cleanQuery: '' };
const trimmed = text.trim();
const match = trimmed.match(
/^((?:no\.?|d\.?no\.?|flat|door|plot|#|house|shop)\s*[\w\d\-\/]+)(?:,\s*|\s+)(.+)$/i
);
if (match) {
return { doorNo: match[1].trim(), cleanQuery: match[2].trim() };
}
return { doorNo: '', cleanQuery: trimmed };
};
/**
* Extracts landmark cues from an address string (e.g. "Opposite Inorbit Mall", "Near Metro Pillar 12")
*/
export const extractLandmark = (address) => {
if (!address) return '';
const match = address.match(/(?:near|opp\.?|opposite|behind|beside|next to|adj\.?|adjacent to)\s+([^,]+)/i);
return match ? match[0].trim() : '';
};
/**
* Detects and parses pasted Google Maps links or raw coordinates
* e.g. "https://maps.google.com/?q=17.4483,78.3915" or "17.4483, 78.3915"
*/
export const parseRawCoordinatesOrUrl = (text) => {
if (!text) return null;
const clean = text.trim();
// 1. Raw Coordinates regex: "17.4483, 78.3915" or "17.4483 78.3915"
const coordMatch = clean.match(/^(-?\d{1,2}\.\d+)[,\s]+(-?\d{1,3}\.\d+)$/);
if (coordMatch) {
const lat = Number(coordMatch[1]);
const lng = Number(coordMatch[2]);
if (isWithinIndia(lat, lng)) return { lat, lng };
}
// 2. Google Maps URL parser: contains q=lat,lng or @lat,lng
const urlCoordMatch = clean.match(/(?:q=|@|destination=)(-?\d{1,2}\.\d+)[,\s]+(-?\d{1,3}\.\d+)/);
if (urlCoordMatch) {
const lat = Number(urlCoordMatch[1]);
const lng = Number(urlCoordMatch[2]);
if (isWithinIndia(lat, lng)) return { lat, lng };
}
return null;
};
const REQUEST_TIMEOUT_MS = 3000;
const fetchWithTimeout = (url, options = {}) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
};
/**
* Standardize place result into unified format
*/ */
export const standardizePlace = ({ export const standardizePlace = ({
formatted_address = '', formatted_address = '',
@@ -58,7 +145,6 @@ export const standardizePlace = ({
longitude: Number.isFinite(lngNum) ? lngNum : null, longitude: Number.isFinite(lngNum) ? lngNum : null,
provider, provider,
raw, raw,
// Google Places / Legacy compatibility interface
geometry: { geometry: {
location: { location: {
lat: () => (Number.isFinite(latNum) ? latNum : 0), lat: () => (Number.isFinite(latNum) ? latNum : 0),
@@ -75,14 +161,46 @@ export const standardizePlace = ({
}; };
/** /**
* 1. Photon (Komoot OSM) Search - Fast, typo-tolerant, ideal for real-time keystroke suggestions * Recent Addresses Local Storage Management
*/ */
async function searchPhoton(query, { bias, limit = 6 } = {}) { export const getRecentAddresses = (limit = 5) => {
try { try {
const saved = localStorage.getItem(RECENT_ADDRESSES_STORAGE_KEY);
if (!saved) return [];
const list = JSON.parse(saved);
return Array.isArray(list) ? list.slice(0, limit) : [];
} catch {
return [];
}
};
export const saveRecentAddress = (place) => {
if (!place || !place.formatted_address || !place.latitude || !place.longitude) return;
try {
const list = getRecentAddresses(10);
const filtered = list.filter(
(item) =>
item.formatted_address !== place.formatted_address &&
Math.hypot(item.latitude - place.latitude, item.longitude - place.longitude) > 0.002
);
filtered.unshift(place);
localStorage.setItem(RECENT_ADDRESSES_STORAGE_KEY, JSON.stringify(filtered.slice(0, 8)));
} catch {}
};
/**
* 1. Photon (Komoot OSM) Dynamic Search
*/
async function searchPhoton(query, { bias, limit = 10 } = {}) {
try {
const { doorNo, cleanQuery } = extractDoorPrefix(query);
const searchQuery = cleanQuery || query;
const params = new URLSearchParams({ const params = new URLSearchParams({
q: query, q: searchQuery,
limit: String(limit), limit: String(limit),
lang: 'en' lang: 'en',
bbox: INDIA_BBOX_PHOTON
}); });
if (bias?.lat && bias?.lng) { if (bias?.lat && bias?.lng) {
@@ -90,7 +208,7 @@ async function searchPhoton(query, { bias, limit = 6 } = {}) {
params.set('lon', String(bias.lng)); params.set('lon', String(bias.lng));
} }
const res = await fetch(`${PHOTON_API_URL}?${params.toString()}`, { const res = await fetchWithTimeout(`${PHOTON_API_URL}?${params.toString()}`, {
headers: { Accept: 'application/json' } headers: { Accept: 'application/json' }
}); });
@@ -98,69 +216,83 @@ async function searchPhoton(query, { bias, limit = 6 } = {}) {
const data = await res.json(); const data = await res.json();
if (!data?.features || !Array.isArray(data.features)) return []; if (!data?.features || !Array.isArray(data.features)) return [];
return data.features.map((item) => { return data.features
const p = item.properties || {}; .filter((item) => {
const [lon, lat] = item.geometry?.coordinates || [null, null]; const [lon, lat] = item.geometry?.coordinates || [null, null];
return isWithinIndia(lat, lon);
})
.map((item) => {
const p = item.properties || {};
const [lon, lat] = item.geometry?.coordinates || [null, null];
const finalHouse = doorNo || p.housenumber || '';
const parts = [ const primaryName = finalHouse
p.name, ? `${finalHouse}, ${p.name || ''}`.trim().replace(/^,\s*/, '')
p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', : p.name || '';
p.district || p.suburb || p.neighbourhood || p.locality,
p.city,
p.state,
p.postcode,
p.country
].filter(Boolean);
// Remove consecutive duplicates in address string const parts = [
const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx); primaryName,
const formatted = uniqueParts.join(', '); p.street ? `${!doorNo && p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
p.district || p.suburb || p.neighbourhood || p.locality,
p.city || p.county || '',
p.state || '',
p.postcode || '',
p.country || 'India'
].filter(Boolean);
return standardizePlace({ const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx);
formatted_address: formatted || p.name || '', const formatted = uniqueParts.join(', ');
name: p.name || formatted.split(',')[0],
suburb: p.district || p.suburb || p.neighbourhood || p.locality || '', return standardizePlace({
city: p.city || p.county || '', formatted_address: formatted || primaryName,
state: p.state || '', name: primaryName || formatted.split(',')[0],
postcode: p.postcode || '', suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
latitude: lat, city: p.city || p.county || '',
longitude: lon, state: p.state || '',
provider: 'photon', postcode: p.postcode || '',
raw: item latitude: lat,
longitude: lon,
provider: 'photon',
raw: item
});
}); });
});
} catch (err) { } catch (err) {
console.warn('[Geocoding] Photon search failed:', err);
return []; return [];
} }
} }
/** /**
* 2. Nominatim Search - Fallback with countrycodes=in & addressdetails * 2. Nominatim Dynamic Search
*/ */
async function searchNominatim(query, { bias, country = 'in', limit = 5 } = {}) { async function searchNominatim(query, { bias, limit = 8 } = {}) {
try { try {
const { doorNo, cleanQuery } = extractDoorPrefix(query);
const searchQuery = cleanQuery || query;
const isPincode = /^[1-9][0-9]{5}$/.test(searchQuery.trim());
const params = new URLSearchParams({ const params = new URLSearchParams({
q: query,
format: 'json', format: 'json',
addressdetails: '1', addressdetails: '1',
limit: String(limit) limit: String(limit),
countrycodes: 'in'
}); });
if (country) { if (isPincode) {
params.set('countrycodes', country.toLowerCase()); params.set('postalcode', searchQuery.trim());
} else {
params.set('q', searchQuery);
} }
if (bias?.lat && bias?.lng) { if (bias?.lat && bias?.lng) {
const d = 0.5; // ~55km bounding box const d = 0.8;
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
} }
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Accept-Language': 'en-GB,en;q=0.9', 'Accept-Language': 'en-GB,en;q=0.9',
'User-Agent': 'DoormileConsole/1.0' 'User-Agent': 'DoormileConsole/2.0'
} }
}); });
@@ -168,34 +300,44 @@ async function searchNominatim(query, { bias, country = 'in', limit = 5 } = {})
const results = await res.json(); const results = await res.json();
if (!Array.isArray(results)) return []; if (!Array.isArray(results)) return [];
return results.map((r) => { return results
const addr = r.address || {}; .filter((r) => isWithinIndia(r.lat, r.lon))
const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || ''; .map((r) => {
const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || ''; const addr = r.address || {};
const state = addr.state || ''; const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || addr.district || '';
const postcode = addr.postcode || ''; const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || '';
const state = addr.state || '';
const postcode = addr.postcode || '';
return standardizePlace({ let displayName = r.display_name || '';
formatted_address: r.display_name || '', if (doorNo && !displayName.toLowerCase().startsWith(doorNo.toLowerCase())) {
name: r.display_name?.split(',')[0] || '', displayName = `${doorNo}, ${displayName}`;
suburb, }
city,
state, const primaryName = doorNo
postcode, ? `${doorNo}, ${r.name || displayName.split(',')[0]}`
latitude: r.lat, : r.name || displayName.split(',')[0];
longitude: r.lon,
provider: 'nominatim', return standardizePlace({
raw: r formatted_address: displayName,
name: primaryName,
suburb,
city,
state,
postcode,
latitude: r.lat,
longitude: r.lon,
provider: 'nominatim',
raw: r
});
}); });
});
} catch (err) { } catch (err) {
console.warn('[Geocoding] Nominatim search failed:', err);
return []; return [];
} }
} }
/** /**
* 3. Ola Maps Places Autocomplete (if key configured) * 3. Ola Maps Places Autocomplete
*/ */
async function searchOlaMaps(query, { bias, limit = 5 } = {}) { async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
if (!OLA_MAPS_KEY) return []; if (!OLA_MAPS_KEY) return [];
@@ -209,65 +351,126 @@ async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
params.set('radius', '50000'); params.set('radius', '50000');
} }
const res = await fetch(`https://api.olamaps.io/places/v1/autocomplete?${params.toString()}`); const res = await fetchWithTimeout(`https://api.olamaps.io/places/v1/autocomplete?${params.toString()}`);
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
if (!data?.predictions || !Array.isArray(data.predictions)) return []; if (!data?.predictions || !Array.isArray(data.predictions)) return [];
return data.predictions.slice(0, limit).map((p) => { return data.predictions
const lat = p.geometry?.location?.lat; .filter((p) => {
const lng = p.geometry?.location?.lng; const lat = p.geometry?.location?.lat;
return standardizePlace({ const lng = p.geometry?.location?.lng;
formatted_address: p.description || '', return isWithinIndia(lat, lng);
name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '', })
suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '', .slice(0, limit)
city: '', .map((p) => {
state: '', const lat = p.geometry?.location?.lat;
postcode: '', const lng = p.geometry?.location?.lng;
latitude: lat, return standardizePlace({
longitude: lng, formatted_address: p.description || '',
provider: 'olamaps', name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '',
raw: p suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '',
city: '',
state: '',
postcode: '',
latitude: lat,
longitude: lng,
provider: 'olamaps',
raw: p
});
}); });
});
} catch (err) { } catch (err) {
console.warn('[Geocoding] Ola Maps autocomplete failed:', err);
return []; return [];
} }
} }
/** /**
* Primary Address Autocomplete Dispatcher * Deduplicates results and ranks by active city or geographic proximity
* Queries fastest/best provider first, then falls back seamlessly.
*/ */
export async function getAddressSuggestions(query, options = {}) { function deduplicateAndRank(places, bias, limit = 8) {
const cleanQuery = (query || '').trim(); const seen = new Set();
if (!cleanQuery || cleanQuery.length < 2) return []; const unique = [];
// 1. Try Ola Maps if API key is present for (const p of places) {
if (OLA_MAPS_KEY) { if (!p || !p.formatted_address) continue;
const olaResults = await searchOlaMaps(cleanQuery, options); const coordKey = p.latitude && p.longitude
if (olaResults.length > 0) return olaResults; ? `${Number(p.latitude).toFixed(3)},${Number(p.longitude).toFixed(3)}`
: p.formatted_address.toLowerCase().slice(0, 35);
if (seen.has(coordKey)) continue;
seen.add(coordKey);
unique.push(p);
} }
// 2. High-speed Photon OSM autocomplete (sub-100ms, typo tolerant) // Sort by bias city or proximity
const photonResults = await searchPhoton(cleanQuery, options); if (bias?.city) {
if (photonResults.length > 0) return photonResults; 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;
});
}
// 3. Fallback to scoped Nominatim return unique.slice(0, limit);
const nominatimResults = await searchNominatim(cleanQuery, options);
return nominatimResults;
} }
/** /**
* Reverse Geocode: Convert Coordinates to structured address * Primary Address Autocomplete Dispatcher
*/ */
export async function reverseGeocode(lat, lng) { export async function getAddressSuggestions(query, options = {}) {
if (lat == null || lng == null) return null; const cleanQuery = (query || '').trim();
if (!cleanQuery) return [];
// 1. Try Photon reverse first // Check if user pasted coordinates or Google Maps link directly
const parsedCoords = parseRawCoordinatesOrUrl(cleanQuery);
if (parsedCoords) {
const directPlace = await reverseGeocode(parsedCoords.lat, parsedCoords.lng);
if (directPlace) return [directPlace];
}
if (cleanQuery.length < 2) return [];
// Parallel Execution
const promises = [];
if (OLA_MAPS_KEY) {
promises.push(searchOlaMaps(cleanQuery, options));
}
promises.push(searchPhoton(cleanQuery, options));
promises.push(searchNominatim(cleanQuery, options));
const results = await Promise.allSettled(promises);
const places = [];
for (const res of results) {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
places.push(...res.value);
}
}
return deduplicateAndRank(places, options.bias, options.limit || 8);
}
/**
* Dynamic Reverse Geocoding
*/
export async function reverseGeocode(lat, lon) {
if (lat == null || lon == null) return null;
const nLat = Number(lat);
const nLon = Number(lon);
if (!isWithinIndia(nLat, nLon)) return null;
// 1. Try Photon reverse
try { try {
const res = await fetch(`${PHOTON_REVERSE_URL}?lat=${lat}&lon=${lng}`, { const res = await fetchWithTimeout(`${PHOTON_REVERSE_URL}?lat=${nLat}&lon=${nLon}`, {
headers: { Accept: 'application/json' } headers: { Accept: 'application/json' }
}); });
if (res.ok) { if (res.ok) {
@@ -279,11 +482,12 @@ export async function reverseGeocode(lat, lng) {
p.name, p.name,
p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '', p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
p.district || p.suburb || p.neighbourhood || p.locality, p.district || p.suburb || p.neighbourhood || p.locality,
p.city, p.city || p.county || '',
p.state, p.state || '',
p.postcode, p.postcode || '',
p.country p.country || 'India'
].filter(Boolean); ].filter(Boolean);
const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', '); const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', ');
return standardizePlace({ return standardizePlace({
@@ -293,25 +497,23 @@ export async function reverseGeocode(lat, lng) {
city: p.city || p.county || '', city: p.city || p.county || '',
state: p.state || '', state: p.state || '',
postcode: p.postcode || '', postcode: p.postcode || '',
latitude: lat, latitude: nLat,
longitude: lng, longitude: nLon,
provider: 'photon_reverse' provider: 'photon_reverse'
}); });
} }
} }
} catch (e) { } catch (e) {}
console.warn('[Geocoding] Photon reverse failed, trying Nominatim:', e);
}
// 2. Fallback to Nominatim reverse // 2. Fallback to Nominatim reverse
try { try {
const res = await fetch( const res = await fetchWithTimeout(
`${NOMINATIM_REVERSE_URL}?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`, `${NOMINATIM_REVERSE_URL}?lat=${nLat}&lon=${nLon}&format=json&zoom=18&addressdetails=1`,
{ {
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Accept-Language': 'en-GB,en;q=0.9', 'Accept-Language': 'en-GB,en;q=0.9',
'User-Agent': 'DoormileConsole/1.0' 'User-Agent': 'DoormileConsole/2.0'
} }
} }
); );
@@ -321,30 +523,28 @@ export async function reverseGeocode(lat, lng) {
return standardizePlace({ return standardizePlace({
formatted_address: r.display_name || '', formatted_address: r.display_name || '',
name: r.display_name?.split(',')[0] || '', name: r.display_name?.split(',')[0] || '',
suburb: addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '', suburb: addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || addr.district || '',
city: addr.city || addr.town || addr.village || addr.city_district || addr.county || '', city: addr.city || addr.town || addr.village || addr.city_district || addr.county || '',
state: addr.state || '', state: addr.state || '',
postcode: addr.postcode || '', postcode: addr.postcode || '',
latitude: lat, latitude: nLat,
longitude: lng, longitude: nLon,
provider: 'nominatim_reverse' provider: 'nominatim_reverse'
}); });
} }
} catch (err) { } catch (err) {}
console.error('[Geocoding] Reverse geocode failed completely:', err);
}
return standardizePlace({ return standardizePlace({
formatted_address: `Location (${Number(lat).toFixed(5)}, ${Number(lng).toFixed(5)})`, formatted_address: `Location (${nLat.toFixed(5)}, ${nLon.toFixed(5)})`,
name: 'Selected Pin', name: 'Selected Pin',
latitude: lat, latitude: nLat,
longitude: lng, longitude: nLon,
provider: 'coordinates_only' provider: 'coordinates_only'
}); });
} }
/** /**
* Direct forward geocode for single address string (used by bulk uploads / fallback) * Direct forward geocode
*/ */
export async function geocodeAddress(address, options = {}) { export async function geocodeAddress(address, options = {}) {
const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 }); const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 });

View File

@@ -23,6 +23,8 @@ import dayjs from 'dayjs';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete'; import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import MapPinPickerModal from '@/components/doormile/MapPinPickerModal'; import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect'; import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet';
import L from 'leaflet';
import { import {
createExpressBooking, createExpressBooking,
getAdminCustomers, getAdminCustomers,
@@ -30,7 +32,8 @@ import {
getAdminTenants, getAdminTenants,
getTenantLocations getTenantLocations
} from '@/api/doormile/endpoints'; } from '@/api/doormile/endpoints';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance'; import { calculateDrivingRoute, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
import { saveRecentAddress } from '@/lib/geocodingService';
import { useHubs } from '@/lib/doormileHooks'; import { useHubs } from '@/lib/doormileHooks';
import { import {
PICKUP_SOURCE, PICKUP_SOURCE,
@@ -41,6 +44,32 @@ import {
} from '@/lib/orderFlow'; } from '@/lib/orderFlow';
import { OpenToast } from '@/components/third-party/OpenToast'; import { OpenToast } from '@/components/third-party/OpenToast';
const miniPickupIcon = new L.DivIcon({
className: 'mini-pickup-icon',
html: `<div style="background-color: #10b981; color: white; width: 22px; height: 22px; border-radius: 9999px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 10px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3);">P</div>`,
iconSize: [22, 22],
iconAnchor: [11, 11]
});
const miniDropIcon = new L.DivIcon({
className: 'mini-drop-icon',
html: `<div style="background-color: #f97316; color: white; width: 22px; height: 22px; border-radius: 9999px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 10px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3);">D</div>`,
iconSize: [22, 22],
iconAnchor: [11, 11]
});
function MiniRouteBoundsAdjuster({ bounds }) {
const map = useMap();
useEffect(() => {
if (bounds && bounds.length >= 2) {
try {
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 15 });
} catch {}
}
}, [bounds, map]);
return null;
}
const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast']; const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
const ITEM_CATEGORIES = ['General', 'Food & Beverage', 'Grocery', 'Pharmacy', 'Documents', 'Electronics']; const ITEM_CATEGORIES = ['General', 'Food & Beverage', 'Grocery', 'Pharmacy', 'Documents', 'Electronics'];
@@ -164,10 +193,17 @@ export default function CreateOrder() {
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({ const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
queryKey: ['adminCustomers'], queryKey: ['adminCustomers'],
queryFn: getAdminCustomers, queryFn: getAdminCustomers
enabled: isCustomerModalOpen
}); });
const matchedCustomer = useMemo(() => {
const cleanPhone = String(dropCust.contactno || '').replace(/\D/g, '');
if (cleanPhone.length >= 8) {
return (customerList || []).find((c) => String(c.phone || '').replace(/\D/g, '').endsWith(cleanPhone));
}
return null;
}, [customerList, dropCust.contactno]);
const filteredCustomers = useMemo(() => { const filteredCustomers = useMemo(() => {
const q = searchCustQuery.trim().toLowerCase(); const q = searchCustQuery.trim().toLowerCase();
if (!q) return customerList; if (!q) return customerList;
@@ -274,7 +310,7 @@ export default function CreateOrder() {
return defaultRule || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 }; return defaultRule || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 };
}, [pricingList, selectedTenantId]); }, [pricingList, selectedTenantId]);
// Route calculation // Route calculation with full polyline for mini-map preview
useEffect(() => { useEffect(() => {
const pLat = Number(pickCust.latitude); const pLat = Number(pickCust.latitude);
const pLng = Number(pickCust.longitude); const pLng = Number(pickCust.longitude);
@@ -282,18 +318,23 @@ export default function CreateOrder() {
const dLng = Number(dropCust.longitude); const dLng = Number(dropCust.longitude);
if (!pLat || !pLng || !dLat || !dLng) { if (!pLat || !pLng || !dLat || !dLng) {
setRoute({ distance: 0, minutes: null, resolved: false }); setRoute({ distance: 0, minutes: null, polyline: [], resolved: false });
return; return;
} }
let active = true; let active = true;
calculateDrivingDistance({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng }) calculateDrivingRoute({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
.then((distance) => { .then((res) => {
if (!active) return; if (!active) return;
setRoute({ distance: Math.max(1, distance || 1), minutes: getLastRouteDurationMin() || 15, resolved: true }); setRoute({
distance: Math.max(1, res.distance || 1),
minutes: res.minutes || 15,
polyline: res.polyline || [[pLat, pLng], [dLat, dLng]],
resolved: true
});
}) })
.catch(() => { .catch(() => {
if (active) setRoute({ distance: 0, minutes: null, resolved: false }); if (active) setRoute({ distance: 0, minutes: null, polyline: [], resolved: false });
}); });
return () => { return () => {
@@ -354,6 +395,25 @@ export default function CreateOrder() {
return Math.max(base, calculated); return Math.max(base, calculated);
}, [route, clientRate]); }, [route, clientRate]);
// Active City & Hub Bias for smart address autocomplete
const activeCityBias = useMemo(() => {
if (selectedLocation?.latitude && selectedLocation?.longitude) {
return {
lat: Number(selectedLocation.latitude),
lng: Number(selectedLocation.longitude),
city: selectedLocation.city || selectedLocation.locationname || ''
};
}
if (pickCust?.latitude && pickCust?.longitude) {
return {
lat: Number(pickCust.latitude),
lng: Number(pickCust.longitude),
city: pickCust.city || ''
};
}
return undefined;
}, [selectedLocation, pickCust.latitude, pickCust.longitude, pickCust.city]);
// Place selection helpers // Place selection helpers
const handlePlaceSelect = (partyType, place) => { const handlePlaceSelect = (partyType, place) => {
if (!place) return; if (!place) return;
@@ -519,6 +579,15 @@ export default function CreateOrder() {
try { try {
const res = await createExpressBooking(payload); const res = await createExpressBooking(payload);
if (res?.success) { if (res?.success) {
saveRecentAddress({
formatted_address: dropCust.address,
name: dropCust.firstname,
suburb: dropCust.suburb,
city: dropCust.city,
postcode: dropCust.postcode,
latitude: Number(dropCust.latitude),
longitude: Number(dropCust.longitude)
});
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] }); queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
queryClient.invalidateQueries({ queryKey: ['ordersBulk'] }); queryClient.invalidateQueries({ queryKey: ['ordersBulk'] });
OpenToast('Order created successfully', 'success', 2500); OpenToast('Order created successfully', 'success', 2500);
@@ -776,11 +845,15 @@ export default function CreateOrder() {
id="pickup-address" id="pickup-address"
label="Pickup Address" label="Pickup Address"
required required
placeholder="Search address, building or locality" placeholder="Search area, street, society, pincode or paste link…"
value={pickCust.address} value={pickCust.address}
bias={activeCityBias}
onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))} onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('pickup', place)} onPlaceSelected={(place) => handlePlaceSelect('pickup', place)}
onOpenMapPin={() => setMapPickerTarget('pickup')} onOpenMapPin={() => setMapPickerTarget('pickup')}
onLandmarkExtracted={(lmk) => {
setPickCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev));
}}
/> />
</div> </div>
@@ -851,6 +924,26 @@ export default function CreateOrder() {
</button> </button>
</div> </div>
{/* Smart Customer Auto-Fill Banner */}
{matchedCustomer && (!dropCust.firstname || dropCust.firstname !== matchedCustomer.name) && (
<div className="p-2.5 bg-blue-50/90 border border-blue-200 rounded-xl flex items-center justify-between gap-3 animate-in fade-in duration-200">
<div className="flex items-center gap-2 text-xs text-blue-900">
<span className="font-bold text-blue-700">✨ Existing Contact Found:</span>
<span className="font-semibold">{matchedCustomer.name}</span>
{matchedCustomer.phone && (
<span className="font-mono text-blue-600 text-[11px]">({matchedCustomer.phone})</span>
)}
</div>
<button
type="button"
onClick={() => handlePickCustomer(matchedCustomer)}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-lg transition-colors cursor-pointer shrink-0 shadow-2xs"
>
Auto-Fill
</button>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold text-slate-800 mb-1"> <label className="block text-xs font-semibold text-slate-800 mb-1">
@@ -887,12 +980,15 @@ export default function CreateOrder() {
id="drop-address" id="drop-address"
label="Delivery Address" label="Delivery Address"
required required
placeholder="Search delivery address, society or landmark" placeholder="Search area, street, society, pincode or paste link…"
value={dropCust.address} value={dropCust.address}
bias={pickCust.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined} bias={activeCityBias}
onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))} onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('drop', place)} onPlaceSelected={(place) => handlePlaceSelect('drop', place)}
onOpenMapPin={() => setMapPickerTarget('drop')} onOpenMapPin={() => setMapPickerTarget('drop')}
onLandmarkExtracted={(lmk) => {
setDropCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev));
}}
/> />
</div> </div>
@@ -1246,6 +1342,52 @@ export default function CreateOrder() {
</div> </div>
</div> </div>
{/* Mini Interactive Route Map Preview */}
{route.resolved && Number(pickCust.latitude) && Number(dropCust.latitude) && (
<div className="rounded-xl overflow-hidden border border-slate-200 shadow-2xs mt-2 relative">
<div className="h-36 w-full relative z-0">
<MapContainer
center={[
(Number(pickCust.latitude) + Number(dropCust.latitude)) / 2,
(Number(pickCust.longitude) + Number(dropCust.longitude)) / 2
]}
zoom={12}
scrollWheelZoom={false}
dragging={false}
zoomControl={false}
attributionControl={false}
className="h-full w-full"
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker
position={[Number(pickCust.latitude), Number(pickCust.longitude)]}
icon={miniPickupIcon}
/>
<Marker
position={[Number(dropCust.latitude), Number(dropCust.longitude)]}
icon={miniDropIcon}
/>
{route.polyline && route.polyline.length > 0 && (
<Polyline positions={route.polyline} color="#2563eb" weight={4} opacity={0.85} />
)}
<MiniRouteBoundsAdjuster
bounds={[
[Number(pickCust.latitude), Number(pickCust.longitude)],
[Number(dropCust.latitude), Number(dropCust.longitude)]
]}
/>
</MapContainer>
</div>
<div className="px-3 py-1.5 bg-slate-50 border-t border-slate-100 flex items-center justify-between text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
Pickup ➔ <span className="w-2 h-2 rounded-full bg-orange-500" /> Delivery
</span>
<span className="font-semibold text-slate-700">{route.distance} km road path</span>
</div>
</div>
)}
{/* Shown only when it exists, and labelled as unbilled so nobody {/* Shown only when it exists, and labelled as unbilled so nobody
reconciles the invoice against the wrong number. */} reconciles the invoice against the wrong number. */}
{isCustomerPickup && deadhead.resolved && ( {isCustomerPickup && deadhead.resolved && (