PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / resources / js / components / trip-form / LocationPicker.tsx

LocationPicker.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.2.8, at resources/js/components/trip-form/LocationPicker.tsx

669 lines 22.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Independent Location Picker Component
3 * Allows users to either manually enter location details or select from OpenStreetMap
4 * Fully reusable across different forms and contexts
5 */
6
7 import React, { useState, useEffect, useRef } from "react";
8 import { MapPin, Globe, X, Loader2 } from "lucide-react";
9
10 // Translation function - can be overridden via props
11 const defaultTranslate = (key: string) => {
12 const translations: Record<string, string> = {
13 "Enter location name": "Enter location name",
14 "Show Map": "Show Map",
15 "Hide Map": "Hide Map",
16 Clear: "Clear",
17 "Coordinates:": "Coordinates:",
18 "Search for a location...": "Search for a location...",
19 "Click on the map to set the location, or search for a place above.":
20 "Click on the map to set the location, or search for a place above.",
21 "GPS Coordinates": "GPS Coordinates",
22 "Manual override": "Manual override",
23 Latitude: "Latitude",
24 Longitude: "Longitude",
25 "e.g. -8.3405": "e.g. -8.3405",
26 "e.g. 115.0920": "e.g. 115.0920",
27 "Use Current Location": "Use Current Location",
28 "Geolocation is not supported by your browser":
29 "Geolocation is not supported by your browser",
30 "Unable to get your location": "Unable to get your location",
31 "Location access requires HTTPS. This feature will work on your live HTTPS site.":
32 "Location access requires HTTPS. This feature will work on your live HTTPS site.",
33 "Manual coordinate entry. These will be auto-filled when you select a location from the map above.":
34 "Manual coordinate entry. These will be auto-filled when you select a location from the map above.",
35 "Click on the map to set the location, or drag the marker to adjust coordinates.":
36 "Click on the map to set the location, or drag the marker to adjust coordinates.",
37 };
38 return translations[key] || key;
39 };
40
41 export interface LocationData {
42 name: string;
43 latitude: string;
44 longitude: string;
45 }
46
47 interface LocationPickerProps {
48 // Core functionality
49 value: LocationData;
50 onChange: (location: LocationData) => void;
51
52 // Display options
53 label?: string;
54 placeholder?: string;
55 helpText?: string;
56 required?: boolean;
57
58 // Map options
59 showMapButton?: boolean;
60 defaultMapCenter?: [number, number]; // [lat, lng]
61 defaultZoom?: number;
62 mapHeight?: string;
63
64 // Search options
65 searchPlaceholder?: string;
66 searchLimit?: number;
67
68 // Styling
69 className?: string;
70 inputClassName?: string;
71 mapClassName?: string;
72
73 // Translation function (optional)
74 __?: (key: string, domain?: string) => string;
75
76 // Events
77 onLocationSelect?: (location: LocationData) => void;
78 onLocationClear?: () => void;
79 onMapToggle?: (isOpen: boolean) => void;
80
81 // Validation
82 validateCoordinates?: (lat: string, lng: string) => boolean;
83 errorMessage?: string;
84
85 /** Show editable latitude/longitude fields (same idea as trip form manual GPS section). */
86 showManualCoordinateFields?: boolean;
87 }
88
89 export const LocationPicker: React.FC<LocationPickerProps> = ({
90 value,
91 onChange,
92 label,
93 helpText,
94 required = false,
95 showMapButton = true,
96 defaultMapCenter = [25.2048, 55.2708], // Dubai coordinates
97 defaultZoom = 13,
98 mapHeight = "300px",
99 searchLimit = 5,
100 className = "",
101 inputClassName = "",
102 mapClassName = "",
103 __: translate = defaultTranslate,
104 onLocationSelect,
105 onLocationClear,
106 onMapToggle,
107 showManualCoordinateFields = false,
108 }) => {
109 const [showMap, setShowMap] = useState(true);
110 const [searchQuery, setSearchQuery] = useState("");
111 const [searchResults, setSearchResults] = useState<any[]>([]);
112 const [searchLoading, setSearchLoading] = useState(false);
113 const [mapLoading, setMapLoading] = useState(false);
114 const [mapReady, setMapReady] = useState(false);
115 const mapRef = useRef<HTMLDivElement>(null);
116 const mapInstanceRef = useRef<any>(null);
117 const markerRef = useRef<any>(null);
118
119 // Update marker when coordinates change and map is already initialized
120 useEffect(() => {
121 if (mapInstanceRef.current && value.latitude && value.longitude) {
122 const lat = parseFloat(value.latitude);
123 const lng = parseFloat(value.longitude);
124
125 if (!isNaN(lat) && !isNaN(lng)) {
126 // Small delay to ensure map is fully ready
127 setTimeout(() => {
128 if (mapInstanceRef.current) {
129 // Add or update marker
130 addMarker(mapInstanceRef.current, lat, lng);
131
132 // Update map view to new coordinates
133 mapInstanceRef.current.setView([lat, lng], defaultZoom);
134 }
135 }, 100);
136 }
137 }
138 }, [value.latitude, value.longitude, defaultZoom]);
139
140 // Load Leaflet dynamically when map is shown
141 useEffect(() => {
142 if (showMap && !mapLoading && !mapInstanceRef.current) {
143 loadMap();
144 }
145 }, [showMap]);
146
147 // Add marker when map becomes ready with existing coordinates
148 useEffect(() => {
149 if (
150 mapReady &&
151 mapInstanceRef.current &&
152 value.latitude &&
153 value.longitude
154 ) {
155 const lat = parseFloat(value.latitude);
156 const lng = parseFloat(value.longitude);
157
158 if (!isNaN(lat) && !isNaN(lng)) {
159 setTimeout(() => {
160 if (mapInstanceRef.current) {
161 addMarker(mapInstanceRef.current, lat, lng);
162 mapInstanceRef.current.setView([lat, lng], defaultZoom);
163 }
164 }, 150);
165 }
166 }
167 }, [mapReady]);
168
169 const loadMap = async () => {
170 setMapLoading(true);
171
172 try {
173 // Load Leaflet CSS and JS
174 if (!document.querySelector('link[href*="leaflet.css"]')) {
175 const leafletCSS = document.createElement("link");
176 leafletCSS.rel = "stylesheet";
177 leafletCSS.href = "https://unpkg.com/[email protected]/dist/leaflet.css";
178 leafletCSS.integrity =
179 "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
180 leafletCSS.crossOrigin = "";
181 document.head.appendChild(leafletCSS);
182 }
183
184 if (!window.L) {
185 const leafletJS = document.createElement("script");
186 leafletJS.src = "https://unpkg.com/[email protected]/dist/leaflet.js";
187 leafletJS.integrity =
188 "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
189 leafletJS.crossOrigin = "";
190 document.head.appendChild(leafletJS);
191
192 leafletJS.onload = initializeMap;
193 } else {
194 initializeMap();
195 }
196 } catch (error) {
197 console.error("Error loading map:", error);
198 setMapLoading(false);
199 }
200 };
201
202 const initializeMap = () => {
203 if (!mapRef.current || !window.L) return;
204
205 const L = window.L;
206
207 // Initialize map with default view or current coordinates
208 const lat = value.latitude
209 ? parseFloat(value.latitude)
210 : defaultMapCenter[0];
211 const lng = value.longitude
212 ? parseFloat(value.longitude)
213 : defaultMapCenter[1];
214
215 const map = L.map(mapRef.current).setView([lat, lng], defaultZoom);
216
217 // Add OpenStreetMap tiles
218 L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
219 attribution:
220 '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
221 maxZoom: 19,
222 }).addTo(map);
223
224 // Add marker if coordinates exist
225 if (value.latitude && value.longitude) {
226 addMarker(map, lat, lng);
227 }
228
229 // Handle map clicks
230 map.on("click", function (e: any) {
231 const { lat, lng } = e.latlng;
232 addMarker(map, lat, lng);
233
234 // Reverse geocode to get location name
235 reverseGeocode(lat, lng);
236 });
237
238 mapInstanceRef.current = map;
239 setMapLoading(false);
240 setMapReady(true);
241 };
242
243 const addMarker = (map: any, lat: number, lng: number) => {
244 const L = window.L;
245
246 // Remove existing marker
247 if (markerRef.current) {
248 map.removeLayer(markerRef.current);
249 }
250
251 // Create custom marker
252 const customIcon = L.divIcon({
253 html: '<div style="background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; width: 30px; height: 30px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3);"><svg style="transform: rotate(45deg); width: 14px; height: 14px;" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" /></svg></div>',
254 iconSize: [30, 30],
255 iconAnchor: [15, 30],
256 className: "yatra-custom-marker",
257 });
258
259 const marker = L.marker([lat, lng], {
260 icon: customIcon,
261 draggable: true,
262 }).addTo(map);
263 markerRef.current = marker;
264
265 // Handle marker drag events
266 marker.on("dragend", function (e: any) {
267 const position = e.target.getLatLng();
268 const newLat = position.lat;
269 const newLng = position.lng;
270
271 // Update form values
272 const newLocation = {
273 ...value,
274 latitude: newLat.toString(),
275 longitude: newLng.toString(),
276 };
277 onChange(newLocation);
278
279 // Reverse geocode to get updated location name
280 reverseGeocode(newLat, newLng);
281 });
282
283 // Update form values
284 const newLocation = {
285 ...value,
286 latitude: lat.toString(),
287 longitude: lng.toString(),
288 };
289 onChange(newLocation);
290 };
291
292 const reverseGeocode = async (lat: number, lng: number) => {
293 try {
294 // Use WordPress AJAX endpoint to avoid CORS issues
295 const formData = new FormData();
296 formData.append("action", "yatra_reverse_geocode");
297 formData.append("lat", lat.toString());
298 formData.append("lng", lng.toString());
299 formData.append(
300 "nonce",
301 (window as any).yatraAdmin?.geocodingNonce || "",
302 );
303
304 const response = await fetch(
305 (window as any).yatraAdmin?.ajaxUrl || "/wp-admin/admin-ajax.php",
306 {
307 method: "POST",
308 body: formData,
309 },
310 );
311
312 if (response.ok) {
313 const result = await response.json();
314 if (result.success && result.data.result) {
315 const data = result.data.result;
316 const locationName =
317 data.display_name || `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
318 const newLocation = {
319 ...value,
320 name: locationName,
321 latitude: lat.toString(),
322 longitude: lng.toString(),
323 };
324 onChange(newLocation);
325 return;
326 }
327 }
328 } catch (error) {
329 console.error("Error reverse geocoding:", error);
330 }
331
332 // Fallback: always update with coordinates if reverse geocoding fails
333 const locationName = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
334 const newLocation = {
335 ...value,
336 name: locationName,
337 latitude: lat.toString(),
338 longitude: lng.toString(),
339 };
340 onChange(newLocation);
341 };
342
343 // Debounced search for better performance
344 const searchTimeoutRef = useRef<number>();
345
346 const searchLocations = async (query: string) => {
347 if (!query.trim() || query.length < 2) {
348 setSearchResults([]);
349 return;
350 }
351
352 setSearchLoading(true);
353 try {
354 // Use WordPress AJAX endpoint to avoid CORS issues
355 const formData = new FormData();
356 formData.append("action", "yatra_search_locations");
357 formData.append("query", query);
358 formData.append("limit", searchLimit.toString());
359 formData.append(
360 "nonce",
361 (window as any).yatraAdmin?.geocodingNonce || "",
362 );
363
364 const response = await fetch(
365 (window as any).yatraAdmin?.ajaxUrl || "/wp-admin/admin-ajax.php",
366 {
367 method: "POST",
368 body: formData,
369 },
370 );
371
372 if (response.ok) {
373 const result = await response.json();
374 if (result.success) {
375 setSearchResults(result.data.results);
376 } else {
377 console.error("Search error:", result.data.message);
378 setSearchResults([]);
379 }
380 }
381 } catch (error) {
382 console.error("Error searching locations:", error);
383 setSearchResults([]);
384 } finally {
385 setSearchLoading(false);
386 }
387 };
388
389 const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
390 const query = e.target.value;
391 setSearchQuery(query);
392
393 // Clear existing timeout
394 if (searchTimeoutRef.current) {
395 clearTimeout(searchTimeoutRef.current);
396 }
397
398 // Debounce search with 300ms delay
399 searchTimeoutRef.current = setTimeout(() => {
400 searchLocations(query);
401 }, 300);
402 };
403
404 const selectLocation = (location: any) => {
405 const lat = parseFloat(location.lat);
406 const lng = parseFloat(location.lon);
407
408 const newLocation = {
409 name: location.display_name,
410 latitude: lat.toString(),
411 longitude: lng.toString(),
412 };
413
414 onChange(newLocation);
415 onLocationSelect?.(newLocation);
416
417 // Update map if it's open
418 if (mapInstanceRef.current) {
419 mapInstanceRef.current.setView([lat, lng], 15);
420 addMarker(mapInstanceRef.current, lat, lng);
421 }
422
423 // Reset search
424 setSearchQuery("");
425 setSearchResults([]);
426 };
427
428 const clearLocation = () => {
429 const emptyLocation = { name: "", latitude: "", longitude: "" };
430 onChange(emptyLocation);
431 onLocationClear?.();
432
433 if (markerRef.current && mapInstanceRef.current) {
434 mapInstanceRef.current.removeLayer(markerRef.current);
435 markerRef.current = null;
436 }
437 };
438
439 const toggleMap = () => {
440 const newState = !showMap;
441 setShowMap(newState);
442 onMapToggle?.(newState);
443 };
444
445 return (
446 <div className={`space-y-3 ${className}`}>
447 <div className="flex items-center justify-between">
448 <label className="block text-xs font-normal text-gray-500 dark:text-gray-400">
449 {label}
450 {required && <span className="text-red-500 ml-1">*</span>}
451 </label>
452 {showMapButton && (
453 <button
454 type="button"
455 onClick={toggleMap}
456 className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100 transition-colors"
457 >
458 <MapPin className="w-3 h-3" />
459 {showMap ? translate("Hide Map") : translate("Show Map")}
460 </button>
461 )}
462 </div>
463
464 {/* Search Input Field with Suggestions */}
465 <div className="relative">
466 <div className="relative">
467 <MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
468 <input
469 type="text"
470 value={searchQuery || value.name}
471 onChange={(e) => {
472 const query = e.target.value;
473 setSearchQuery(query);
474 handleSearchChange({
475 target: { value: query },
476 } as React.ChangeEvent<HTMLInputElement>);
477 // Also update the location name directly for manual entry
478 onChange({ ...value, name: query });
479 }}
480 placeholder="Type location here..."
481 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}`}
482 />
483 {searchLoading ? (
484 <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 animate-spin text-gray-400" />
485 ) : searchQuery || value.name ? (
486 <button
487 type="button"
488 onClick={() => {
489 clearLocation();
490 setSearchQuery("");
491 }}
492 className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
493 >
494 <X className="w-4 h-4" />
495 </button>
496 ) : null}
497 </div>
498
499 {/* Search Results Dropdown */}
500 {searchResults.length > 0 && (
501 <div
502 className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-64 overflow-y-auto"
503 style={{ zIndex: 9999 }}
504 >
505 {searchResults.map((result, index) => (
506 <button
507 key={index}
508 type="button"
509 onClick={() => selectLocation(result)}
510 className="w-full text-left px-4 py-3 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors border-b border-gray-100 dark:border-gray-700 last:border-b-0 group"
511 >
512 <div className="flex items-start gap-3">
513 <div className="w-5 h-5 flex-shrink-0 mt-0.5">
514 <MapPin className="w-4 h-4 text-blue-500 group-hover:text-blue-600" />
515 </div>
516 <div className="flex-1 min-w-0">
517 <div className="font-medium text-gray-900 dark:text-white text-sm truncate">
518 {result.display_name.split(",")[0]}
519 </div>
520 <div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
521 {result.display_name.split(",").slice(1).join(",").trim()}
522 </div>
523 </div>
524 </div>
525 </button>
526 ))}
527 </div>
528 )}
529 </div>
530
531 {/* Coordinates Display (compact) — hidden when manual fields are shown below */}
532 {!showManualCoordinateFields && (value.latitude || value.longitude) && (
533 <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
534 <Globe className="w-4 h-4 text-gray-500" />
535 <span className="text-xs text-gray-600 dark:text-gray-400">
536 {translate("Coordinates:")} {value.latitude || "0"},{" "}
537 {value.longitude || "0"}
538 </span>
539 </div>
540 )}
541
542 {/* Map Interface */}
543 {showMap && (
544 <div className="border border-gray-300 rounded-lg overflow-hidden">
545 {/* Map Container */}
546 <div className="relative">
547 {mapLoading && (
548 <div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
549 <Loader2 className="w-6 h-6 animate-spin text-blue-500" />
550 </div>
551 )}
552 <div
553 ref={mapRef}
554 style={{ height: mapHeight, width: "100%" }}
555 className={`bg-gray-100 ${mapClassName}`}
556 />
557 </div>
558
559 {/* Instructions */}
560 <div className="p-3 bg-gray-50 dark:bg-gray-800 text-xs text-gray-600 dark:text-gray-400">
561 {translate(
562 "Click on the map to set the location, or drag the marker to adjust coordinates.",
563 )}
564 </div>
565 </div>
566 )}
567
568 {showManualCoordinateFields && (
569 <div className="space-y-3 pt-2 border-t border-gray-200 dark:border-gray-700">
570 <div className="flex flex-wrap items-center justify-between gap-2">
571 <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
572 {translate("GPS Coordinates")}
573 <span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">
574 ({translate("Manual override")})
575 </span>
576 </span>
577 <button
578 type="button"
579 onClick={() => {
580 if (!navigator.geolocation) {
581 alert(
582 translate("Geolocation is not supported by your browser"),
583 );
584 return;
585 }
586 navigator.geolocation.getCurrentPosition(
587 (position) => {
588 onChange({
589 ...value,
590 latitude: position.coords.latitude.toString(),
591 longitude: position.coords.longitude.toString(),
592 });
593 },
594 (error) => {
595 let message = translate("Unable to get your location");
596 if (
597 error.code === 1 &&
598 error.message.includes("secure origins")
599 ) {
600 message = translate(
601 "Location access requires HTTPS. This feature will work on your live HTTPS site.",
602 );
603 }
604 alert(message);
605 },
606 );
607 }}
608 className="text-xs bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 px-3 py-1.5 rounded-lg hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors"
609 >
610 {translate("Use Current Location")}
611 </button>
612 </div>
613 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
614 <div className="space-y-1.5">
615 <label className="text-xs font-medium text-gray-600 dark:text-gray-400">
616 {translate("Latitude")}
617 </label>
618 <input
619 type="text"
620 inputMode="decimal"
621 value={value.latitude}
622 onChange={(e) =>
623 onChange({ ...value, latitude: e.target.value })
624 }
625 placeholder={translate("e.g. -8.3405")}
626 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}`}
627 />
628 </div>
629 <div className="space-y-1.5">
630 <label className="text-xs font-medium text-gray-600 dark:text-gray-400">
631 {translate("Longitude")}
632 </label>
633 <input
634 type="text"
635 inputMode="decimal"
636 value={value.longitude}
637 onChange={(e) =>
638 onChange({ ...value, longitude: e.target.value })
639 }
640 placeholder={translate("e.g. 115.0920")}
641 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}`}
642 />
643 </div>
644 </div>
645 <p className="text-xs text-gray-500 dark:text-gray-400">
646 {translate(
647 "Manual coordinate entry. These will be auto-filled when you select a location from the map above.",
648 )}
649 </p>
650 </div>
651 )}
652
653 {/* Help Text */}
654 {helpText && (
655 <div className="text-xs text-gray-500 dark:text-gray-400">
656 {helpText}
657 </div>
658 )}
659 </div>
660 );
661 };
662
663 // Add TypeScript declaration for Leaflet
664 declare global {
665 interface Window {
666 L: any;
667 }
668 }
669