/** * Independent Location Picker Component * Allows users to either manually enter location details or select from OpenStreetMap * Fully reusable across different forms and contexts */ import React, { useState, useEffect, useRef } from "react"; import { MapPin, Globe, X, Loader2 } from "lucide-react"; // Translation function - can be overridden via props const defaultTranslate = (key: string) => { const translations: Record = { "Enter location name": "Enter location name", "Show Map": "Show Map", "Hide Map": "Hide Map", Clear: "Clear", "Coordinates:": "Coordinates:", "Search for a location...": "Search for a location...", "Click on the map to set the location, or search for a place above.": "Click on the map to set the location, or search for a place above.", "GPS Coordinates": "GPS Coordinates", "Manual override": "Manual override", Latitude: "Latitude", Longitude: "Longitude", "e.g. -8.3405": "e.g. -8.3405", "e.g. 115.0920": "e.g. 115.0920", "Use Current Location": "Use Current Location", "Geolocation is not supported by your browser": "Geolocation is not supported by your browser", "Unable to get your location": "Unable to get your location", "Location access requires HTTPS. This feature will work on your live HTTPS site.": "Location access requires HTTPS. This feature will work on your live HTTPS site.", "Manual coordinate entry. These will be auto-filled when you select a location from the map above.": "Manual coordinate entry. These will be auto-filled when you select a location from the map above.", "Click on the map to set the location, or drag the marker to adjust coordinates.": "Click on the map to set the location, or drag the marker to adjust coordinates.", }; return translations[key] || key; }; export interface LocationData { name: string; latitude: string; longitude: string; } interface LocationPickerProps { // Core functionality value: LocationData; onChange: (location: LocationData) => void; // Display options label?: string; placeholder?: string; helpText?: string; required?: boolean; // Map options showMapButton?: boolean; defaultMapCenter?: [number, number]; // [lat, lng] defaultZoom?: number; mapHeight?: string; // Search options searchPlaceholder?: string; searchLimit?: number; // Styling className?: string; inputClassName?: string; mapClassName?: string; // Translation function (optional) __?: (key: string, domain?: string) => string; // Events onLocationSelect?: (location: LocationData) => void; onLocationClear?: () => void; onMapToggle?: (isOpen: boolean) => void; // Validation validateCoordinates?: (lat: string, lng: string) => boolean; errorMessage?: string; /** Show editable latitude/longitude fields (same idea as trip form manual GPS section). */ showManualCoordinateFields?: boolean; } export const LocationPicker: React.FC = ({ value, onChange, label, helpText, required = false, showMapButton = true, /** Neutral world view when no coordinates (avoid implying a random region). */ defaultMapCenter = [20, 0], defaultZoom = 2, mapHeight = "300px", searchLimit = 5, className = "", inputClassName = "", mapClassName = "", __: translate = defaultTranslate, onLocationSelect, onLocationClear, onMapToggle, showManualCoordinateFields = false, }) => { const [showMap, setShowMap] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [mapLoading, setMapLoading] = useState(false); const [mapReady, setMapReady] = useState(false); const mapRef = useRef(null); const mapInstanceRef = useRef(null); const markerRef = useRef(null); const valueRef = useRef(value); const defaultMapCenterRef = useRef(defaultMapCenter); const defaultZoomRef = useRef(defaultZoom); const pendingSelectionRef = useRef<{ lat: number; lng: number; zoom: number; name?: string; } | null>(null); useEffect(() => { valueRef.current = value; }, [value]); useEffect(() => { defaultMapCenterRef.current = defaultMapCenter; }, [defaultMapCenter]); useEffect(() => { defaultZoomRef.current = defaultZoom; }, [defaultZoom]); useEffect(() => { return () => { if (mapInstanceRef.current) { try { mapInstanceRef.current.remove(); } catch { /* ignore */ } mapInstanceRef.current = null; markerRef.current = null; } }; }, []); // Update marker when coordinates change and map is already initialized useEffect(() => { if (mapInstanceRef.current && value.latitude && value.longitude) { const lat = parseFloat(value.latitude); const lng = parseFloat(value.longitude); if (!isNaN(lat) && !isNaN(lng)) { // Small delay to ensure map is fully ready setTimeout(() => { if (mapInstanceRef.current) { // Add or update marker addMarker(mapInstanceRef.current, lat, lng); // Update map view to new coordinates mapInstanceRef.current.setView([lat, lng], defaultZoom); } }, 100); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value.latitude, value.longitude, defaultZoom]); // Load Leaflet dynamically when map is shown useEffect(() => { if (showMap && !mapLoading && !mapInstanceRef.current) { loadMap(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [showMap]); // Add marker when map becomes ready with existing coordinates useEffect(() => { if ( mapReady && mapInstanceRef.current && value.latitude && value.longitude ) { const lat = parseFloat(value.latitude); const lng = parseFloat(value.longitude); if (!isNaN(lat) && !isNaN(lng)) { setTimeout(() => { if (mapInstanceRef.current) { addMarker(mapInstanceRef.current, lat, lng); mapInstanceRef.current.setView([lat, lng], defaultZoom); } }, 150); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapReady, value.latitude, value.longitude, defaultZoom]); const loadMap = async () => { setMapLoading(true); try { // Load Leaflet CSS and JS if (!document.querySelector('link[href*="leaflet.css"]')) { const leafletCSS = document.createElement("link"); leafletCSS.rel = "stylesheet"; leafletCSS.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; leafletCSS.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; leafletCSS.crossOrigin = ""; document.head.appendChild(leafletCSS); } if (!window.L) { const leafletJS = document.createElement("script"); leafletJS.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; leafletJS.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; leafletJS.crossOrigin = ""; document.head.appendChild(leafletJS); leafletJS.onload = () => initializeMap(); } else { initializeMap(); } } catch (error) { console.error("Error loading map:", error); setMapLoading(false); } }; const initializeMap = () => { if (!mapRef.current || !window.L) return; const L = window.L; const v = valueRef.current; const center = defaultMapCenterRef.current; const zoomCfg = defaultZoomRef.current; // Initialize map with default view or current coordinates (always read latest refs — avoids stale closure when Leaflet loads async) const lat = v.latitude?.trim() ? parseFloat(v.latitude) : center[0]; const lng = v.longitude?.trim() ? parseFloat(v.longitude) : center[1]; const map = L.map(mapRef.current).setView([lat, lng], zoomCfg); // Add OpenStreetMap tiles L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(map); const pending = pendingSelectionRef.current; if (pending) { pendingSelectionRef.current = null; map.setView([pending.lat, pending.lng], pending.zoom); addMarker( map, pending.lat, pending.lng, pending.name ? { name: pending.name } : undefined, ); } else if (v.latitude?.trim() && v.longitude?.trim()) { addMarker(map, lat, lng); } // Handle map clicks map.on("click", function (e: any) { const { lat, lng } = e.latlng; addMarker(map, lat, lng); // Reverse geocode to get location name reverseGeocode(lat, lng); }); mapInstanceRef.current = map; setMapLoading(false); setMapReady(true); }; const addMarker = ( map: any, lat: number, lng: number, extra?: Partial, ) => { const L = window.L; // Remove existing marker if (markerRef.current) { map.removeLayer(markerRef.current); } // Create custom marker const customIcon = L.divIcon({ html: '
', iconSize: [30, 30], iconAnchor: [15, 30], className: "yatra-custom-marker", }); const marker = L.marker([lat, lng], { icon: customIcon, draggable: true, }).addTo(map); markerRef.current = marker; // Handle marker drag events marker.on("dragend", function (e: any) { const position = e.target.getLatLng(); const newLat = position.lat; const newLng = position.lng; // Update form values const newLocation = { ...valueRef.current, latitude: newLat.toString(), longitude: newLng.toString(), }; onChange(newLocation); valueRef.current = newLocation; // Reverse geocode to get updated location name reverseGeocode(newLat, newLng); }); // Update form values (merge extra e.g. display_name from search — avoids stale `value` wiping the label) const newLocation = { ...valueRef.current, ...extra, latitude: lat.toString(), longitude: lng.toString(), }; onChange(newLocation); valueRef.current = newLocation; }; const reverseGeocode = async (lat: number, lng: number) => { try { // Use WordPress AJAX endpoint to avoid CORS issues const formData = new FormData(); formData.append("action", "yatra_reverse_geocode"); formData.append("lat", lat.toString()); formData.append("lng", lng.toString()); formData.append( "nonce", (window as any).yatraAdmin?.geocodingNonce || "", ); const response = await fetch( (window as any).yatraAdmin?.ajaxUrl || "/wp-admin/admin-ajax.php", { method: "POST", body: formData, }, ); if (response.ok) { const result = await response.json(); if (result.success && result.data.result) { const data = result.data.result; const locationName = data.display_name || `${lat.toFixed(6)}, ${lng.toFixed(6)}`; const newLocation = { ...valueRef.current, name: locationName, latitude: lat.toString(), longitude: lng.toString(), }; onChange(newLocation); valueRef.current = newLocation; return; } } } catch (error) { console.error("Error reverse geocoding:", error); } // Fallback: always update with coordinates if reverse geocoding fails const locationName = `${lat.toFixed(6)}, ${lng.toFixed(6)}`; const newLocation = { ...valueRef.current, name: locationName, latitude: lat.toString(), longitude: lng.toString(), }; onChange(newLocation); valueRef.current = newLocation; }; // Debounced search for better performance const searchTimeoutRef = useRef(); const searchLocations = async (query: string) => { if (!query.trim() || query.length < 2) { setSearchResults([]); return; } setSearchLoading(true); try { // Use WordPress AJAX endpoint to avoid CORS issues const formData = new FormData(); formData.append("action", "yatra_search_locations"); formData.append("query", query); formData.append("limit", searchLimit.toString()); formData.append( "nonce", (window as any).yatraAdmin?.geocodingNonce || "", ); const response = await fetch( (window as any).yatraAdmin?.ajaxUrl || "/wp-admin/admin-ajax.php", { method: "POST", body: formData, }, ); if (response.ok) { const result = await response.json(); if (result.success) { setSearchResults(result.data.results); } else { console.error("Search error:", result.data.message); setSearchResults([]); } } } catch (error) { console.error("Error searching locations:", error); setSearchResults([]); } finally { setSearchLoading(false); } }; const handleSearchChange = (e: React.ChangeEvent) => { const query = e.target.value; setSearchQuery(query); // Clear existing timeout if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } // Debounce search with 300ms delay searchTimeoutRef.current = setTimeout(() => { searchLocations(query); }, 300); }; const selectLocation = (location: any) => { const lat = parseFloat(location.lat); const lng = parseFloat(location.lon); const displayName = (location.display_name as string) || ""; const newLocation = { name: displayName, latitude: lat.toString(), longitude: lng.toString(), }; onChange(newLocation); valueRef.current = newLocation; onLocationSelect?.(newLocation); const detailZoom = 14; if (mapInstanceRef.current) { mapInstanceRef.current.setView([lat, lng], detailZoom); addMarker(mapInstanceRef.current, lat, lng, { name: displayName }); } else { pendingSelectionRef.current = { lat, lng, zoom: detailZoom, name: displayName, }; } // Reset search setSearchQuery(""); setSearchResults([]); }; const clearLocation = () => { pendingSelectionRef.current = null; const emptyLocation = { name: "", latitude: "", longitude: "" }; onChange(emptyLocation); valueRef.current = emptyLocation; onLocationClear?.(); if (markerRef.current && mapInstanceRef.current) { mapInstanceRef.current.removeLayer(markerRef.current); markerRef.current = null; } }; const toggleMap = () => { const newState = !showMap; setShowMap(newState); onMapToggle?.(newState); }; return (
{showMapButton && ( )}
{/* Search Input Field with Suggestions */}
{ const query = e.target.value; setSearchQuery(query); handleSearchChange({ target: { value: query }, } as React.ChangeEvent); // Also update the location name directly for manual entry onChange({ ...value, name: query }); }} placeholder="Type location here..." className={`w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white ${inputClassName}`} /> {searchLoading ? ( ) : searchQuery || value.name ? ( ) : null}
{/* Search Results Dropdown */} {searchResults.length > 0 && (
{searchResults.map((result, index) => ( ))}
)}
{/* Coordinates Display (compact) — hidden when manual fields are shown below */} {!showManualCoordinateFields && (value.latitude || value.longitude) && (
{translate("Coordinates:")} {value.latitude || "0"},{" "} {value.longitude || "0"}
)} {/* Map Interface */} {showMap && (
{/* Map Container */}
{mapLoading && (
)}
{/* Instructions */}
{translate( "Click on the map to set the location, or drag the marker to adjust coordinates.", )}
)} {showManualCoordinateFields && (
{translate("GPS Coordinates")} ({translate("Manual override")})
onChange({ ...value, latitude: e.target.value }) } placeholder={translate("e.g. -8.3405")} className={`w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white ${inputClassName}`} />
onChange({ ...value, longitude: e.target.value }) } placeholder={translate("e.g. 115.0920")} className={`w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white ${inputClassName}`} />

{translate( "Manual coordinate entry. These will be auto-filled when you select a location from the map above.", )}

)} {/* Help Text */} {helpText && (
{helpText}
)}
); }; // Add TypeScript declaration for Leaflet declare global { interface Window { L: any; } }