From bc79ba4912bfac3aff60ddfafee255b9baee4041 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Fri, 11 Sep 2026 15:39:35 +0530 Subject: [PATCH] updates on the autocomplete in the create order page --- .../doormile/AddressAutocomplete.jsx | 240 ++++++--- src/lib/distance.js | 95 ++-- src/lib/geocodingService.js | 458 +++++++++++++----- src/pages/doormile/orders/CreateOrder.jsx | 166 ++++++- 4 files changed, 719 insertions(+), 240 deletions(-) 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 && ( +
- )} + + {onOpenMapPin && ( + + )} +
@@ -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 && ( - + + + ); + })} + +
)} ); @@ -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}) + )} +
+ +
+ )} +
@@ -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 && (