PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.16 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 All 84 releases
yatra / resources / js / components / trip-form / LocationPicker.tsx

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

738 lines 24.5 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 /** Neutral world view when no coordinates (avoid implying a random region). */
97 defaultMapCenter = [20, 0],
98 defaultZoom = 2,
99 mapHeight = "300px",
100 searchLimit = 5,
101 className = "",
102 inputClassName = "",
103 mapClassName = "",
104 __: translate = defaultTranslate,
105 onLocationSelect,
106 onLocationClear,
107 onMapToggle,
108 showManualCoordinateFields = false,
109 }) => {
110 const [showMap, setShowMap] = useState(true);
111 const [searchQuery, setSearchQuery] = useState("");
112 const [searchResults, setSearchResults] = useState<any[]>([]);
113 const [searchLoading, setSearchLoading] = useState(false);
114 const [mapLoading, setMapLoading] = useState(false);
115 const [mapReady, setMapReady] = useState(false);
116 const mapRef = useRef<HTMLDivElement>(null);
117 const mapInstanceRef = useRef<any>(null);
118 const markerRef = useRef<any>(null);
119 const valueRef = useRef(value);
120 const defaultMapCenterRef = useRef(defaultMapCenter);
121 const defaultZoomRef = useRef(defaultZoom);
122 const pendingSelectionRef = useRef<{
123 lat: number;
124 lng: number;
125 zoom: number;
126 name?: string;
127 } | null>(null);
128
129 useEffect(() => {
130 valueRef.current = value;
131 }, [value]);
132
133 useEffect(() => {
134 defaultMapCenterRef.current = defaultMapCenter;
135 }, [defaultMapCenter]);
136
137 useEffect(() => {
138 defaultZoomRef.current = defaultZoom;
139 }, [defaultZoom]);
140
141 useEffect(() => {
142 return () => {
143 if (mapInstanceRef.current) {
144 try {
145 mapInstanceRef.current.remove();
146 } catch {
147 /* ignore */
148 }
149 mapInstanceRef.current = null;
150 markerRef.current = null;
151 }
152 };
153 }, []);
154
155 // Update marker when coordinates change and map is already initialized
156 useEffect(() => {
157 if (mapInstanceRef.current && value.latitude && value.longitude) {
158 const lat = parseFloat(value.latitude);
159 const lng = parseFloat(value.longitude);
160
161 if (!isNaN(lat) && !isNaN(lng)) {
162 // Small delay to ensure map is fully ready
163 setTimeout(() => {
164 if (mapInstanceRef.current) {
165 // Add or update marker
166 addMarker(mapInstanceRef.current, lat, lng);
167
168 // Update map view to new coordinates
169 mapInstanceRef.current.setView([lat, lng], defaultZoom);
170 }
171 }, 100);
172 }
173 }
174 // eslint-disable-next-line react-hooks/exhaustive-deps
175 }, [value.latitude, value.longitude, defaultZoom]);
176
177 // Load Leaflet dynamically when map is shown
178 useEffect(() => {
179 if (showMap && !mapLoading && !mapInstanceRef.current) {
180 loadMap();
181 }
182 // eslint-disable-next-line react-hooks/exhaustive-deps
183 }, [showMap]);
184
185 // Add marker when map becomes ready with existing coordinates
186 useEffect(() => {
187 if (
188 mapReady &&
189 mapInstanceRef.current &&
190 value.latitude &&
191 value.longitude
192 ) {
193 const lat = parseFloat(value.latitude);
194 const lng = parseFloat(value.longitude);
195
196 if (!isNaN(lat) && !isNaN(lng)) {
197 setTimeout(() => {
198 if (mapInstanceRef.current) {
199 addMarker(mapInstanceRef.current, lat, lng);
200 mapInstanceRef.current.setView([lat, lng], defaultZoom);
201 }
202 }, 150);
203 }
204 }
205 // eslint-disable-next-line react-hooks/exhaustive-deps
206 }, [mapReady, value.latitude, value.longitude, defaultZoom]);
207
208 const loadMap = async () => {
209 setMapLoading(true);
210
211 try {
212 // Load Leaflet CSS and JS
213 if (!document.querySelector('link[href*="leaflet.css"]')) {
214 const leafletCSS = document.createElement("link");
215 leafletCSS.rel = "stylesheet";
216 leafletCSS.href = "https://unpkg.com/[email protected]/dist/leaflet.css";
217 leafletCSS.integrity =
218 "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
219 leafletCSS.crossOrigin = "";
220 document.head.appendChild(leafletCSS);
221 }
222
223 if (!window.L) {
224 const leafletJS = document.createElement("script");
225 leafletJS.src = "https://unpkg.com/[email protected]/dist/leaflet.js";
226 leafletJS.integrity =
227 "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
228 leafletJS.crossOrigin = "";
229 document.head.appendChild(leafletJS);
230
231 leafletJS.onload = () => initializeMap();
232 } else {
233 initializeMap();
234 }
235 } catch (error) {
236 console.error("Error loading map:", error);
237 setMapLoading(false);
238 }
239 };
240
241 const initializeMap = () => {
242 if (!mapRef.current || !window.L) return;
243
244 const L = window.L;
245 const v = valueRef.current;
246 const center = defaultMapCenterRef.current;
247 const zoomCfg = defaultZoomRef.current;
248
249 // Initialize map with default view or current coordinates (always read latest refs — avoids stale closure when Leaflet loads async)
250 const lat = v.latitude?.trim() ? parseFloat(v.latitude) : center[0];
251 const lng = v.longitude?.trim() ? parseFloat(v.longitude) : center[1];
252
253 const map = L.map(mapRef.current).setView([lat, lng], zoomCfg);
254
255 // Add OpenStreetMap tiles
256 L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
257 attribution:
258 '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
259 maxZoom: 19,
260 }).addTo(map);
261
262 const pending = pendingSelectionRef.current;
263 if (pending) {
264 pendingSelectionRef.current = null;
265 map.setView([pending.lat, pending.lng], pending.zoom);
266 addMarker(
267 map,
268 pending.lat,
269 pending.lng,
270 pending.name ? { name: pending.name } : undefined,
271 );
272 } else if (v.latitude?.trim() && v.longitude?.trim()) {
273 addMarker(map, lat, lng);
274 }
275
276 // Handle map clicks
277 map.on("click", function (e: any) {
278 const { lat, lng } = e.latlng;
279 addMarker(map, lat, lng);
280
281 // Reverse geocode to get location name
282 reverseGeocode(lat, lng);
283 });
284
285 mapInstanceRef.current = map;
286 setMapLoading(false);
287 setMapReady(true);
288 };
289
290 const addMarker = (
291 map: any,
292 lat: number,
293 lng: number,
294 extra?: Partial<LocationData>,
295 ) => {
296 const L = window.L;
297
298 // Remove existing marker
299 if (markerRef.current) {
300 map.removeLayer(markerRef.current);
301 }
302
303 // Create custom marker
304 const customIcon = L.divIcon({
305 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>',
306 iconSize: [30, 30],
307 iconAnchor: [15, 30],
308 className: "yatra-custom-marker",
309 });
310
311 const marker = L.marker([lat, lng], {
312 icon: customIcon,
313 draggable: true,
314 }).addTo(map);
315 markerRef.current = marker;
316
317 // Handle marker drag events
318 marker.on("dragend", function (e: any) {
319 const position = e.target.getLatLng();
320 const newLat = position.lat;
321 const newLng = position.lng;
322
323 // Update form values
324 const newLocation = {
325 ...valueRef.current,
326 latitude: newLat.toString(),
327 longitude: newLng.toString(),
328 };
329 onChange(newLocation);
330 valueRef.current = newLocation;
331
332 // Reverse geocode to get updated location name
333 reverseGeocode(newLat, newLng);
334 });
335
336 // Update form values (merge extra e.g. display_name from search — avoids stale `value` wiping the label)
337 const newLocation = {
338 ...valueRef.current,
339 ...extra,
340 latitude: lat.toString(),
341 longitude: lng.toString(),
342 };
343 onChange(newLocation);
344 valueRef.current = newLocation;
345 };
346
347 const reverseGeocode = async (lat: number, lng: number) => {
348 try {
349 // Use WordPress AJAX endpoint to avoid CORS issues
350 const formData = new FormData();
351 formData.append("action", "yatra_reverse_geocode");
352 formData.append("lat", lat.toString());
353 formData.append("lng", lng.toString());
354 formData.append(
355 "nonce",
356 (window as any).yatraAdmin?.geocodingNonce || "",
357 );
358
359 const response = await fetch(
360 (window as any).yatraAdmin?.ajaxUrl || "/wp-admin/admin-ajax.php",
361 {
362 method: "POST",
363 body: formData,
364 },
365 );
366
367 if (response.ok) {
368 const result = await response.json();
369 if (result.success && result.data.result) {
370 const data = result.data.result;
371 const locationName =
372 data.display_name || `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
373 const newLocation = {
374 ...valueRef.current,
375 name: locationName,
376 latitude: lat.toString(),
377 longitude: lng.toString(),
378 };
379 onChange(newLocation);
380 valueRef.current = newLocation;
381 return;
382 }
383 }
384 } catch (error) {
385 console.error("Error reverse geocoding:", error);
386 }
387
388 // Fallback: always update with coordinates if reverse geocoding fails
389 const locationName = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
390 const newLocation = {
391 ...valueRef.current,
392 name: locationName,
393 latitude: lat.toString(),
394 longitude: lng.toString(),
395 };
396 onChange(newLocation);
397 valueRef.current = newLocation;
398 };
399
400 // Debounced search for better performance
401 const searchTimeoutRef = useRef<number>();
402
403 const searchLocations = async (query: string) => {
404 if (!query.trim() || query.length < 2) {
405 setSearchResults([]);
406 return;
407 }
408
409 setSearchLoading(true);
410 try {
411 // Use WordPress AJAX endpoint to avoid CORS issues
412 const formData = new FormData();
413 formData.append("action", "yatra_search_locations");
414 formData.append("query", query);
415 formData.append("limit", searchLimit.toString());
416 formData.append(
417 "nonce",
418 (window as any).yatraAdmin?.geocodingNonce || "",
419 );
420
421 const response = await fetch(
422 (window as any).yatraAdmin?.ajaxUrl || "/wp-admin/admin-ajax.php",
423 {
424 method: "POST",
425 body: formData,
426 },
427 );
428
429 if (response.ok) {
430 const result = await response.json();
431 if (result.success) {
432 setSearchResults(result.data.results);
433 } else {
434 console.error("Search error:", result.data.message);
435 setSearchResults([]);
436 }
437 }
438 } catch (error) {
439 console.error("Error searching locations:", error);
440 setSearchResults([]);
441 } finally {
442 setSearchLoading(false);
443 }
444 };
445
446 const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
447 const query = e.target.value;
448 setSearchQuery(query);
449
450 // Clear existing timeout
451 if (searchTimeoutRef.current) {
452 clearTimeout(searchTimeoutRef.current);
453 }
454
455 // Debounce search with 300ms delay
456 searchTimeoutRef.current = setTimeout(() => {
457 searchLocations(query);
458 }, 300);
459 };
460
461 const selectLocation = (location: any) => {
462 const lat = parseFloat(location.lat);
463 const lng = parseFloat(location.lon);
464 const displayName = (location.display_name as string) || "";
465
466 const newLocation = {
467 name: displayName,
468 latitude: lat.toString(),
469 longitude: lng.toString(),
470 };
471
472 onChange(newLocation);
473 valueRef.current = newLocation;
474 onLocationSelect?.(newLocation);
475
476 const detailZoom = 14;
477
478 if (mapInstanceRef.current) {
479 mapInstanceRef.current.setView([lat, lng], detailZoom);
480 addMarker(mapInstanceRef.current, lat, lng, { name: displayName });
481 } else {
482 pendingSelectionRef.current = {
483 lat,
484 lng,
485 zoom: detailZoom,
486 name: displayName,
487 };
488 }
489
490 // Reset search
491 setSearchQuery("");
492 setSearchResults([]);
493 };
494
495 const clearLocation = () => {
496 pendingSelectionRef.current = null;
497 const emptyLocation = { name: "", latitude: "", longitude: "" };
498 onChange(emptyLocation);
499 valueRef.current = emptyLocation;
500 onLocationClear?.();
501
502 if (markerRef.current && mapInstanceRef.current) {
503 mapInstanceRef.current.removeLayer(markerRef.current);
504 markerRef.current = null;
505 }
506 };
507
508 const toggleMap = () => {
509 const newState = !showMap;
510 setShowMap(newState);
511 onMapToggle?.(newState);
512 };
513
514 return (
515 <div className={`space-y-3 ${className}`}>
516 <div className="flex items-center justify-between">
517 <label className="block text-xs font-normal text-gray-500 dark:text-gray-400">
518 {label}
519 {required && <span className="text-red-500 ml-1">*</span>}
520 </label>
521 {showMapButton && (
522 <button
523 type="button"
524 onClick={toggleMap}
525 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"
526 >
527 <MapPin className="w-3 h-3" />
528 {showMap ? translate("Hide Map") : translate("Show Map")}
529 </button>
530 )}
531 </div>
532
533 {/* Search Input Field with Suggestions */}
534 <div className="relative">
535 <div className="relative">
536 <MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
537 <input
538 type="text"
539 value={searchQuery || value.name}
540 onChange={(e) => {
541 const query = e.target.value;
542 setSearchQuery(query);
543 handleSearchChange({
544 target: { value: query },
545 } as React.ChangeEvent<HTMLInputElement>);
546 // Also update the location name directly for manual entry
547 onChange({ ...value, name: query });
548 }}
549 placeholder="Type location here..."
550 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}`}
551 />
552 {searchLoading ? (
553 <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 animate-spin text-gray-400" />
554 ) : searchQuery || value.name ? (
555 <button
556 type="button"
557 onClick={() => {
558 clearLocation();
559 setSearchQuery("");
560 }}
561 className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
562 >
563 <X className="w-4 h-4" />
564 </button>
565 ) : null}
566 </div>
567
568 {/* Search Results Dropdown */}
569 {searchResults.length > 0 && (
570 <div
571 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"
572 style={{ zIndex: 9999 }}
573 >
574 {searchResults.map((result, index) => (
575 <button
576 key={index}
577 type="button"
578 onClick={() => selectLocation(result)}
579 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"
580 >
581 <div className="flex items-start gap-3">
582 <div className="w-5 h-5 flex-shrink-0 mt-0.5">
583 <MapPin className="w-4 h-4 text-blue-500 group-hover:text-blue-600" />
584 </div>
585 <div className="flex-1 min-w-0">
586 <div className="font-medium text-gray-900 dark:text-white text-sm truncate">
587 {result.display_name.split(",")[0]}
588 </div>
589 <div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
590 {result.display_name.split(",").slice(1).join(",").trim()}
591 </div>
592 </div>
593 </div>
594 </button>
595 ))}
596 </div>
597 )}
598 </div>
599
600 {/* Coordinates Display (compact) — hidden when manual fields are shown below */}
601 {!showManualCoordinateFields && (value.latitude || value.longitude) && (
602 <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
603 <Globe className="w-4 h-4 text-gray-500" />
604 <span className="text-xs text-gray-600 dark:text-gray-400">
605 {translate("Coordinates:")} {value.latitude || "0"},{" "}
606 {value.longitude || "0"}
607 </span>
608 </div>
609 )}
610
611 {/* Map Interface */}
612 {showMap && (
613 <div className="border border-gray-300 rounded-lg overflow-hidden">
614 {/* Map Container */}
615 <div className="relative">
616 {mapLoading && (
617 <div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
618 <Loader2 className="w-6 h-6 animate-spin text-blue-500" />
619 </div>
620 )}
621 <div
622 ref={mapRef}
623 style={{ height: mapHeight, width: "100%" }}
624 className={`bg-gray-100 ${mapClassName}`}
625 />
626 </div>
627
628 {/* Instructions */}
629 <div className="p-3 bg-gray-50 dark:bg-gray-800 text-xs text-gray-600 dark:text-gray-400">
630 {translate(
631 "Click on the map to set the location, or drag the marker to adjust coordinates.",
632 )}
633 </div>
634 </div>
635 )}
636
637 {showManualCoordinateFields && (
638 <div className="space-y-3 pt-2 border-t border-gray-200 dark:border-gray-700">
639 <div className="flex flex-wrap items-center justify-between gap-2">
640 <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
641 {translate("GPS Coordinates")}
642 <span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">
643 ({translate("Manual override")})
644 </span>
645 </span>
646 <button
647 type="button"
648 onClick={() => {
649 if (!navigator.geolocation) {
650 alert(
651 translate("Geolocation is not supported by your browser"),
652 );
653 return;
654 }
655 navigator.geolocation.getCurrentPosition(
656 (position) => {
657 onChange({
658 ...value,
659 latitude: position.coords.latitude.toString(),
660 longitude: position.coords.longitude.toString(),
661 });
662 },
663 (error) => {
664 let message = translate("Unable to get your location");
665 if (
666 error.code === 1 &&
667 error.message.includes("secure origins")
668 ) {
669 message = translate(
670 "Location access requires HTTPS. This feature will work on your live HTTPS site.",
671 );
672 }
673 alert(message);
674 },
675 );
676 }}
677 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"
678 >
679 {translate("Use Current Location")}
680 </button>
681 </div>
682 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
683 <div className="space-y-1.5">
684 <label className="text-xs font-medium text-gray-600 dark:text-gray-400">
685 {translate("Latitude")}
686 </label>
687 <input
688 type="text"
689 inputMode="decimal"
690 value={value.latitude}
691 onChange={(e) =>
692 onChange({ ...value, latitude: e.target.value })
693 }
694 placeholder={translate("e.g. -8.3405")}
695 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}`}
696 />
697 </div>
698 <div className="space-y-1.5">
699 <label className="text-xs font-medium text-gray-600 dark:text-gray-400">
700 {translate("Longitude")}
701 </label>
702 <input
703 type="text"
704 inputMode="decimal"
705 value={value.longitude}
706 onChange={(e) =>
707 onChange({ ...value, longitude: e.target.value })
708 }
709 placeholder={translate("e.g. 115.0920")}
710 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}`}
711 />
712 </div>
713 </div>
714 <p className="text-xs text-gray-500 dark:text-gray-400">
715 {translate(
716 "Manual coordinate entry. These will be auto-filled when you select a location from the map above.",
717 )}
718 </p>
719 </div>
720 )}
721
722 {/* Help Text */}
723 {helpText && (
724 <div className="text-xs text-gray-500 dark:text-gray-400">
725 {helpText}
726 </div>
727 )}
728 </div>
729 );
730 };
731
732 // Add TypeScript declaration for Leaflet
733 declare global {
734 interface Window {
735 L: any;
736 }
737 }
738