diff --git a/src/components/doormile/AddressAutocomplete.jsx b/src/components/doormile/AddressAutocomplete.jsx
index eb5fae4..488a8ce 100644
--- a/src/components/doormile/AddressAutocomplete.jsx
+++ b/src/components/doormile/AddressAutocomplete.jsx
@@ -1,7 +1,16 @@
import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react';
-import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react';
-import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService';
+import { Clock, Compass, Crosshair, Loader2, MapPin, Navigation, X } from 'lucide-react';
+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);
@@ -25,11 +34,12 @@ const useDebouncedCallback = (callback, delay) => {
const AddressAutocomplete = ({
id,
label,
- placeholder = 'Search address or locality…',
+ placeholder = 'Search area, street, society, pincode or paste link…',
value,
onChange,
onPlaceSelected,
onOpenMapPin,
+ onLandmarkExtracted,
bias,
fullWidth = true,
disabled = false,
@@ -40,8 +50,10 @@ const AddressAutocomplete = ({
const [inputValue, setInputValue] = useState(value || '');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
+ const [isLocating, setIsLocating] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [activeOption, setActiveOption] = useState(-1);
+ const [isRecentMode, setIsRecentMode] = useState(false);
const wrapRef = useRef(null);
const activeRef = useRef(0);
@@ -55,36 +67,59 @@ const AddressAutocomplete = ({
}
}, [value]);
- const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
+ const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''},${bias?.city ?? ''}`;
const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current;
- getAddressSuggestions(query, { bias, limit: 6 })
+ getAddressSuggestions(query, { bias, limit: 8 })
.then((results) => {
if (seq !== activeRef.current) return;
setLoading(false);
setOptions(results || []);
+ setIsRecentMode(false);
setIsOpen((results || []).length > 0);
})
.catch(() => {
if (seq !== activeRef.current) return;
setLoading(false);
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(() => {
if (skipNextSearchRef.current) {
skipNextSearchRef.current = false;
return;
}
- if (!inputValue || inputValue.length < 2) {
+ if (!inputValue || inputValue.trim().length < 2) {
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);
- setIsOpen(false);
return;
}
+
setLoading(true);
fetchPredictions(inputValue);
}, [inputValue, biasKey]);
@@ -108,12 +143,53 @@ const AddressAutocomplete = ({
skipNextSearchRef.current = true;
activeRef.current++;
setInputValue(formatted);
+ saveRecentAddress(option);
onChange?.(formatted);
onPlaceSelected?.(option);
+
+ // Extract landmark if present
+ const extractedLmk = extractLandmark(formatted);
+ if (extractedLmk && onLandmarkExtracted) {
+ onLandmarkExtracted(extractedLmk);
+ }
+
setIsOpen(false);
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 listOpen = isOpen && options.length > 0;
if (e.key === 'Escape') {
@@ -142,17 +218,34 @@ const AddressAutocomplete = ({
{label} {required && * }
)}
- {onOpenMapPin && (
+
-
- Pin on Map
+ {isLocating ? (
+
+ ) : (
+
+ )}
+ Current GPS
- )}
+
+ {onOpenMapPin && (
+
+
+ Pin on Map
+
+ )}
+
@@ -167,6 +260,7 @@ const AddressAutocomplete = ({
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined}
+ onFocus={handleFocus}
onChange={(e) => {
setInputValue(e.target.value);
onChange?.(e.target.value);
@@ -180,7 +274,7 @@ const AddressAutocomplete = ({
{/* Action icons on the right */}
- {loading ? (
+ {loading || isLocating ? (
@@ -190,6 +284,7 @@ const AddressAutocomplete = ({
onClick={() => {
setInputValue('');
onChange?.('');
+ setOptions([]);
setIsOpen(false);
}}
className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 cursor-pointer"
@@ -213,57 +308,79 @@ const AddressAutocomplete = ({
{isOpen && options.length > 0 && (
-
- {options.map((option, index) => {
- const isHighlight = index === activeOption;
- return (
-
- setActiveOption(index)}
- 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"
+ {isRecentMode && (
+
+
+
+ Recent Deliveries
+
+ Click to reuse
+
+ )}
+
+
+ {options.map((option, index) => {
+ const isHighlight = index === activeOption;
+ const mainTitle = option.name || option.formatted_address.split(',')[0];
+
+ return (
+
-
-
-
-
-
-
- {option.name || option.formatted_address.split(',')[0]}
-
- {option.suburb && (
-
- {option.suburb}
-
- )}
- {option.postcode && (
-
- {option.postcode}
-
- )}
+
setActiveOption(index)}
+ 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"
+ >
+
+
-
- {option.formatted_address}
+
+
+
+ {mainTitle}
+
+
+ {option.suburb && option.suburb !== mainTitle && (
+
+ {option.suburb}
+
+ )}
+
+ {option.city && (
+
+ {option.city}
+
+ )}
+
+ {option.postcode && (
+
+ {option.postcode}
+
+ )}
+
+
+ {option.formatted_address}
+
-
-
-
- );
- })}
-
+
+
+ );
+ })}
+
+
)}
);
@@ -277,6 +394,7 @@ AddressAutocomplete.propTypes = {
onChange: PropTypes.func,
onPlaceSelected: PropTypes.func,
onOpenMapPin: PropTypes.func,
+ onLandmarkExtracted: PropTypes.func,
bias: PropTypes.object,
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,
diff --git a/src/lib/distance.js b/src/lib/distance.js
index aff13de..f2cc58d 100644
--- a/src/lib/distance.js
+++ b/src/lib/distance.js
@@ -1,50 +1,58 @@
/**
- * Calculates distance (in km) between origin and destination.
- * Uses OSRM as primary driving distance source, and falls back to Haversine with a 1.3 multiplier.
- *
- * @param {Object} origin - { latitude, longitude }
- * @param {Object} destination - { latitude, longitude }
- * @returns {Promise} - Round distance in KM
+ * Driving Distance, Duration & Route Polyline Calculator for DoorMile
+ * Uses OSRM as primary routing engine with GeoJSON polyline extraction,
+ * and falls back seamlessly to Haversine with a 1.3 road winding multiplier.
*/
-// 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;
export const getLastRouteDurationMin = () => lastRouteDurationMin;
-export const calculateDrivingDistance = async (origin, destination) => {
- const lat1 = origin?.latitude;
- const lon1 = origin?.longitude;
- const lat2 = destination?.latitude;
- const lon2 = destination?.longitude;
+/**
+ * Calculates complete driving route with distance, duration, and road polyline
+ * @param {Object} origin - { latitude, longitude }
+ * @param {Object} destination - { latitude, 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");
}
- // 1. Try OSRM API (either self-hosted or public demo server for fallback)
- const osrmBaseUrl = import.meta.env.VITE_OSRM_URL || "https://router.project-osrm.org";
+ // 1. Try OSRM API with full GeoJSON geometry for live polyline map preview
+ const osrmBaseUrl =
+ (typeof import.meta !== 'undefined' && import.meta?.env?.VITE_OSRM_URL) ||
+ 'https://router.project-osrm.org';
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);
if (response.ok) {
const data = await response.json();
if (data.routes && data.routes.length > 0) {
- const distanceInMeters = data.routes[0].distance;
- const distanceInKm = distanceInMeters / 1000;
- // OSRM already returns the driving duration alongside the distance;
- // it was being discarded. Cached here so callers that want an ETA get
- // a REAL routed figure instead of dividing km by a guessed speed.
- lastRouteDurationMin = Number.isFinite(data.routes[0].duration) ? Math.round(data.routes[0].duration / 60) : null;
- return Math.round(distanceInKm);
+ const route = data.routes[0];
+ const distanceKm = Math.round(route.distance / 1000);
+ const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null;
+ // OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng]
+ const polyline = (route.geometry?.coordinates || []).map(([lng, lat]) => [lat, lng]);
+ lastRouteDurationMin = durationMin;
+ return {
+ distance: Math.max(0, distanceKm),
+ minutes: durationMin,
+ polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]],
+ resolved: true
+ };
}
}
} catch (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 R = 6371; // Earth radius in km
const dLat = toRad(lat2 - lat1);
@@ -54,25 +62,36 @@ export const calculateDrivingDistance = async (origin, destination) => {
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
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;
- 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
- *
- * @param {number} distanceKm
- * @param {number} basePrice
- * @param {number} pricePerKm
- * @param {number} minKm
- * @returns {number}
*/
export const calculateTotalCharge = (distanceKm, basePrice, pricePerKm, minKm) => {
- if (distanceKm < minKm) {
- return basePrice;
+ const dist = Number(distanceKm) || 0;
+ 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;
};
diff --git a/src/lib/geocodingService.js b/src/lib/geocodingService.js
index f5c1fc1..33f21a6 100644
--- a/src/lib/geocodingService.js
+++ b/src/lib/geocodingService.js
@@ -1,11 +1,14 @@
/**
- * Enterprise Geocoding & Address Autocomplete Service
+ * Enterprise Dynamic Geocoding & Address Autocomplete Service for DoorMile
*
- * Supports multi-provider hierarchy:
- * 1. Ola Maps Places Autocomplete (if VITE_OLA_MAPS_API_KEY is configured)
- * 2. Google Places API (if VITE_GOOGLE_MAPS_API_KEY is configured)
- * 3. Photon (Komoot OSM - high-speed, typo-tolerant, sub-100ms autocomplete)
- * 4. Nominatim (OpenStreetMap standard geocoder with country lock)
+ * 100% Dynamic, Functional Geocoding Pipeline:
+ * 1. Door / Flat / Plot Prefix Parser
+ * 2. Google Maps URL & Raw Coordinates Paste Parser
+ * 3. 6-Digit Indian Pincode Auto-Resolver
+ * 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) => {
@@ -29,8 +32,92 @@ const PHOTON_REVERSE_URL = 'https://photon.komoot.io/reverse';
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
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 = ({
formatted_address = '',
@@ -58,7 +145,6 @@ export const standardizePlace = ({
longitude: Number.isFinite(lngNum) ? lngNum : null,
provider,
raw,
- // Google Places / Legacy compatibility interface
geometry: {
location: {
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 {
+ 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({
- q: query,
+ q: searchQuery,
limit: String(limit),
- lang: 'en'
+ lang: 'en',
+ bbox: INDIA_BBOX_PHOTON
});
if (bias?.lat && bias?.lng) {
@@ -90,7 +208,7 @@ async function searchPhoton(query, { bias, limit = 6 } = {}) {
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' }
});
@@ -98,69 +216,83 @@ async function searchPhoton(query, { bias, limit = 6 } = {}) {
const data = await res.json();
if (!data?.features || !Array.isArray(data.features)) return [];
- return data.features.map((item) => {
- const p = item.properties || {};
- const [lon, lat] = item.geometry?.coordinates || [null, null];
+ return data.features
+ .filter((item) => {
+ 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 = [
- p.name,
- p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
- p.district || p.suburb || p.neighbourhood || p.locality,
- p.city,
- p.state,
- p.postcode,
- p.country
- ].filter(Boolean);
+ const primaryName = finalHouse
+ ? `${finalHouse}, ${p.name || ''}`.trim().replace(/^,\s*/, '')
+ : p.name || '';
- // Remove consecutive duplicates in address string
- const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx);
- const formatted = uniqueParts.join(', ');
+ const parts = [
+ primaryName,
+ 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({
- formatted_address: formatted || p.name || '',
- name: p.name || formatted.split(',')[0],
- suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
- city: p.city || p.county || '',
- state: p.state || '',
- postcode: p.postcode || '',
- latitude: lat,
- longitude: lon,
- provider: 'photon',
- raw: item
+ const uniqueParts = parts.filter((part, idx, arr) => arr.indexOf(part) === idx);
+ const formatted = uniqueParts.join(', ');
+
+ return standardizePlace({
+ formatted_address: formatted || primaryName,
+ name: primaryName || formatted.split(',')[0],
+ suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
+ city: p.city || p.county || '',
+ state: p.state || '',
+ postcode: p.postcode || '',
+ latitude: lat,
+ longitude: lon,
+ provider: 'photon',
+ raw: item
+ });
});
- });
} catch (err) {
- console.warn('[Geocoding] Photon search failed:', err);
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 {
+ const { doorNo, cleanQuery } = extractDoorPrefix(query);
+ const searchQuery = cleanQuery || query;
+ const isPincode = /^[1-9][0-9]{5}$/.test(searchQuery.trim());
+
const params = new URLSearchParams({
- q: query,
format: 'json',
addressdetails: '1',
- limit: String(limit)
+ limit: String(limit),
+ countrycodes: 'in'
});
- if (country) {
- params.set('countrycodes', country.toLowerCase());
+ if (isPincode) {
+ params.set('postalcode', searchQuery.trim());
+ } else {
+ params.set('q', searchQuery);
}
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}`);
}
- const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
+ const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
headers: {
Accept: 'application/json',
'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();
if (!Array.isArray(results)) return [];
- return results.map((r) => {
- const addr = r.address || {};
- const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || '';
- const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || '';
- const state = addr.state || '';
- const postcode = addr.postcode || '';
+ return results
+ .filter((r) => isWithinIndia(r.lat, r.lon))
+ .map((r) => {
+ const addr = r.address || {};
+ const suburb = addr.suburb || addr.neighbourhood || addr.quarter || addr.residential || addr.district || '';
+ const city = addr.city || addr.town || addr.village || addr.city_district || addr.county || '';
+ const state = addr.state || '';
+ const postcode = addr.postcode || '';
- return standardizePlace({
- formatted_address: r.display_name || '',
- name: r.display_name?.split(',')[0] || '',
- suburb,
- city,
- state,
- postcode,
- latitude: r.lat,
- longitude: r.lon,
- provider: 'nominatim',
- raw: r
+ let displayName = r.display_name || '';
+ if (doorNo && !displayName.toLowerCase().startsWith(doorNo.toLowerCase())) {
+ displayName = `${doorNo}, ${displayName}`;
+ }
+
+ const primaryName = doorNo
+ ? `${doorNo}, ${r.name || displayName.split(',')[0]}`
+ : r.name || displayName.split(',')[0];
+
+ return standardizePlace({
+ formatted_address: displayName,
+ name: primaryName,
+ suburb,
+ city,
+ state,
+ postcode,
+ latitude: r.lat,
+ longitude: r.lon,
+ provider: 'nominatim',
+ raw: r
+ });
});
- });
} catch (err) {
- console.warn('[Geocoding] Nominatim search failed:', err);
return [];
}
}
/**
- * 3. Ola Maps Places Autocomplete (if key configured)
+ * 3. Ola Maps Places Autocomplete
*/
async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
if (!OLA_MAPS_KEY) return [];
@@ -209,65 +351,126 @@ async function searchOlaMaps(query, { bias, limit = 5 } = {}) {
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 [];
const data = await res.json();
if (!data?.predictions || !Array.isArray(data.predictions)) return [];
- return data.predictions.slice(0, limit).map((p) => {
- const lat = p.geometry?.location?.lat;
- const lng = p.geometry?.location?.lng;
- return standardizePlace({
- formatted_address: p.description || '',
- name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '',
- suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '',
- city: '',
- state: '',
- postcode: '',
- latitude: lat,
- longitude: lng,
- provider: 'olamaps',
- raw: p
+ return data.predictions
+ .filter((p) => {
+ const lat = p.geometry?.location?.lat;
+ const lng = p.geometry?.location?.lng;
+ return isWithinIndia(lat, lng);
+ })
+ .slice(0, limit)
+ .map((p) => {
+ const lat = p.geometry?.location?.lat;
+ const lng = p.geometry?.location?.lng;
+ return standardizePlace({
+ formatted_address: p.description || '',
+ name: p.structured_formatting?.main_text || p.description?.split(',')[0] || '',
+ suburb: p.structured_formatting?.secondary_text?.split(',')[0] || '',
+ city: '',
+ state: '',
+ postcode: '',
+ latitude: lat,
+ longitude: lng,
+ provider: 'olamaps',
+ raw: p
+ });
});
- });
} catch (err) {
- console.warn('[Geocoding] Ola Maps autocomplete failed:', err);
return [];
}
}
/**
- * Primary Address Autocomplete Dispatcher
- * Queries fastest/best provider first, then falls back seamlessly.
+ * Deduplicates results and ranks by active city or geographic proximity
*/
-export async function getAddressSuggestions(query, options = {}) {
- const cleanQuery = (query || '').trim();
- if (!cleanQuery || cleanQuery.length < 2) return [];
+function deduplicateAndRank(places, bias, limit = 8) {
+ const seen = new Set();
+ const unique = [];
- // 1. Try Ola Maps if API key is present
- if (OLA_MAPS_KEY) {
- const olaResults = await searchOlaMaps(cleanQuery, options);
- if (olaResults.length > 0) return olaResults;
+ for (const p of places) {
+ if (!p || !p.formatted_address) continue;
+ const coordKey = p.latitude && p.longitude
+ ? `${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)
- const photonResults = await searchPhoton(cleanQuery, options);
- if (photonResults.length > 0) return photonResults;
+ // 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;
+ });
+ }
- // 3. Fallback to scoped Nominatim
- const nominatimResults = await searchNominatim(cleanQuery, options);
- return nominatimResults;
+ return unique.slice(0, limit);
}
/**
- * Reverse Geocode: Convert Coordinates to structured address
+ * Primary Address Autocomplete Dispatcher
*/
-export async function reverseGeocode(lat, lng) {
- if (lat == null || lng == null) return null;
+export async function getAddressSuggestions(query, options = {}) {
+ 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 {
- 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' }
});
if (res.ok) {
@@ -279,11 +482,12 @@ export async function reverseGeocode(lat, lng) {
p.name,
p.street ? `${p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
p.district || p.suburb || p.neighbourhood || p.locality,
- p.city,
- p.state,
- p.postcode,
- p.country
+ p.city || p.county || '',
+ p.state || '',
+ p.postcode || '',
+ p.country || 'India'
].filter(Boolean);
+
const formatted = parts.filter((part, idx, arr) => arr.indexOf(part) === idx).join(', ');
return standardizePlace({
@@ -293,25 +497,23 @@ export async function reverseGeocode(lat, lng) {
city: p.city || p.county || '',
state: p.state || '',
postcode: p.postcode || '',
- latitude: lat,
- longitude: lng,
+ latitude: nLat,
+ longitude: nLon,
provider: 'photon_reverse'
});
}
}
- } catch (e) {
- console.warn('[Geocoding] Photon reverse failed, trying Nominatim:', e);
- }
+ } catch (e) {}
// 2. Fallback to Nominatim reverse
try {
- const res = await fetch(
- `${NOMINATIM_REVERSE_URL}?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`,
+ const res = await fetchWithTimeout(
+ `${NOMINATIM_REVERSE_URL}?lat=${nLat}&lon=${nLon}&format=json&zoom=18&addressdetails=1`,
{
headers: {
Accept: 'application/json',
'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({
formatted_address: r.display_name || '',
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 || '',
state: addr.state || '',
postcode: addr.postcode || '',
- latitude: lat,
- longitude: lng,
+ latitude: nLat,
+ longitude: nLon,
provider: 'nominatim_reverse'
});
}
- } catch (err) {
- console.error('[Geocoding] Reverse geocode failed completely:', err);
- }
+ } catch (err) {}
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',
- latitude: lat,
- longitude: lng,
+ latitude: nLat,
+ longitude: nLon,
provider: 'coordinates_only'
});
}
/**
- * Direct forward geocode for single address string (used by bulk uploads / fallback)
+ * Direct forward geocode
*/
export async function geocodeAddress(address, options = {}) {
const suggestions = await getAddressSuggestions(address, { ...options, limit: 1 });
diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx
index 6d65a5a..99d745a 100644
--- a/src/pages/doormile/orders/CreateOrder.jsx
+++ b/src/pages/doormile/orders/CreateOrder.jsx
@@ -23,6 +23,8 @@ import dayjs from 'dayjs';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
+import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet';
+import L from 'leaflet';
import {
createExpressBooking,
getAdminCustomers,
@@ -30,7 +32,8 @@ import {
getAdminTenants,
getTenantLocations
} 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 {
PICKUP_SOURCE,
@@ -41,6 +44,32 @@ import {
} from '@/lib/orderFlow';
import { OpenToast } from '@/components/third-party/OpenToast';
+const miniPickupIcon = new L.DivIcon({
+ className: 'mini-pickup-icon',
+ html: `P
`,
+ iconSize: [22, 22],
+ iconAnchor: [11, 11]
+});
+
+const miniDropIcon = new L.DivIcon({
+ className: 'mini-drop-icon',
+ html: `D
`,
+ 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 ITEM_CATEGORIES = ['General', 'Food & Beverage', 'Grocery', 'Pharmacy', 'Documents', 'Electronics'];
@@ -164,10 +193,17 @@ export default function CreateOrder() {
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
queryKey: ['adminCustomers'],
- queryFn: getAdminCustomers,
- enabled: isCustomerModalOpen
+ queryFn: getAdminCustomers
});
+ 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 q = searchCustQuery.trim().toLowerCase();
if (!q) return customerList;
@@ -274,7 +310,7 @@ export default function CreateOrder() {
return defaultRule || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 };
}, [pricingList, selectedTenantId]);
- // Route calculation
+ // Route calculation with full polyline for mini-map preview
useEffect(() => {
const pLat = Number(pickCust.latitude);
const pLng = Number(pickCust.longitude);
@@ -282,18 +318,23 @@ export default function CreateOrder() {
const dLng = Number(dropCust.longitude);
if (!pLat || !pLng || !dLat || !dLng) {
- setRoute({ distance: 0, minutes: null, resolved: false });
+ setRoute({ distance: 0, minutes: null, polyline: [], resolved: false });
return;
}
let active = true;
- calculateDrivingDistance({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
- .then((distance) => {
+ calculateDrivingRoute({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
+ .then((res) => {
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(() => {
- if (active) setRoute({ distance: 0, minutes: null, resolved: false });
+ if (active) setRoute({ distance: 0, minutes: null, polyline: [], resolved: false });
});
return () => {
@@ -354,6 +395,25 @@ export default function CreateOrder() {
return Math.max(base, calculated);
}, [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
const handlePlaceSelect = (partyType, place) => {
if (!place) return;
@@ -519,6 +579,15 @@ export default function CreateOrder() {
try {
const res = await createExpressBooking(payload);
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: ['ordersBulk'] });
OpenToast('Order created successfully', 'success', 2500);
@@ -776,11 +845,15 @@ export default function CreateOrder() {
id="pickup-address"
label="Pickup Address"
required
- placeholder="Search address, building or locality"
+ placeholder="Search area, street, society, pincode or paste link…"
value={pickCust.address}
+ bias={activeCityBias}
onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('pickup', place)}
onOpenMapPin={() => setMapPickerTarget('pickup')}
+ onLandmarkExtracted={(lmk) => {
+ setPickCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev));
+ }}
/>
@@ -851,6 +924,26 @@ export default function CreateOrder() {
+ {/* Smart Customer Auto-Fill Banner */}
+ {matchedCustomer && (!dropCust.firstname || dropCust.firstname !== matchedCustomer.name) && (
+
+
+ ✨ Existing Contact Found:
+ {matchedCustomer.name}
+ {matchedCustomer.phone && (
+ ({matchedCustomer.phone})
+ )}
+
+
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
+
+
+ )}
+
@@ -887,12 +980,15 @@ export default function CreateOrder() {
id="drop-address"
label="Delivery Address"
required
- placeholder="Search delivery address, society or landmark"
+ placeholder="Search area, street, society, pincode or paste link…"
value={dropCust.address}
- bias={pickCust.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined}
+ bias={activeCityBias}
onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('drop', place)}
onOpenMapPin={() => setMapPickerTarget('drop')}
+ onLandmarkExtracted={(lmk) => {
+ setDropCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev));
+ }}
/>
@@ -1246,6 +1342,52 @@ export default function CreateOrder() {
+ {/* Mini Interactive Route Map Preview */}
+ {route.resolved && Number(pickCust.latitude) && Number(dropCust.latitude) && (
+
+
+
+
+
+
+ {route.polyline && route.polyline.length > 0 && (
+
+ )}
+
+
+
+
+
+
+ Pickup ➔ Delivery
+
+ {route.distance} km road path
+
+
+ )}
+
{/* Shown only when it exists, and labelled as unbilled so nobody
reconciles the invoice against the wrong number. */}
{isCustomerPickup && deadhead.resolved && (