240 lines
9.0 KiB
TypeScript
240 lines
9.0 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { X, MapPin, Search, Loader2 } from 'lucide-react';
|
|
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
|
|
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import { useTranslation } from '../hooks/useTranslation';
|
|
|
|
// Fix Leaflet default marker icon bug
|
|
const DefaultIcon = L.icon({
|
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
});
|
|
|
|
interface CoordinateSelectModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
initialLat?: number;
|
|
initialLng?: number;
|
|
onSelect: (lat: number, lng: number) => void;
|
|
}
|
|
|
|
function MapClickEvents({ onClick }: { onClick: (lat: number, lng: number) => void }) {
|
|
useMapEvents({
|
|
click: (e) => {
|
|
onClick(e.latlng.lat, e.latlng.lng);
|
|
}
|
|
});
|
|
return null;
|
|
}
|
|
|
|
function MapInvalidator() {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
map.invalidateSize();
|
|
}, 250);
|
|
return () => clearTimeout(timer);
|
|
}, [map]);
|
|
return null;
|
|
}
|
|
|
|
function RecenterMap({ position }: { position: [number, number] }) {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
map.setView(position, map.getZoom());
|
|
}, [position, map]);
|
|
return null;
|
|
}
|
|
|
|
export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
initialLat,
|
|
initialLng,
|
|
onSelect
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
|
|
const [position, setPosition] = useState<[number, number]>(defaultCenter);
|
|
const [hasSelected, setHasSelected] = useState(false);
|
|
|
|
// Search States
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
|
|
setPosition([initialLat, initialLng]);
|
|
setHasSelected(true);
|
|
} else {
|
|
setPosition(defaultCenter);
|
|
setHasSelected(false);
|
|
}
|
|
setSearchQuery('');
|
|
setSearchResults([]);
|
|
}
|
|
}, [isOpen, initialLat, initialLng]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleMapClick = (lat: number, lng: number) => {
|
|
setPosition([lat, lng]);
|
|
setHasSelected(true);
|
|
};
|
|
|
|
const handleConfirm = () => {
|
|
onSelect(position[0], position[1]);
|
|
onClose();
|
|
};
|
|
|
|
const handleSearch = async () => {
|
|
if (!searchQuery.trim()) return;
|
|
setIsSearching(true);
|
|
try {
|
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&accept-language=vi&limit=5`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setSearchResults(data);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error during Nominatim search:', e);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectResult = (place: any) => {
|
|
const lat = parseFloat(place.lat);
|
|
const lng = parseFloat(place.lon);
|
|
setPosition([lat, lng]);
|
|
setHasSelected(true);
|
|
setSearchResults([]);
|
|
setSearchQuery(place.display_name);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Content */}
|
|
<div className="relative w-full max-w-2xl h-[550px] bg-white dark:bg-slate-900 rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 dark:border-slate-800 animate-in zoom-in-95 duration-200">
|
|
{/* Header */}
|
|
<div className="p-4 border-b border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="w-5 h-5 text-blue-500" />
|
|
<div className="text-left">
|
|
<h3 className="font-extrabold text-sm text-gray-900 dark:text-white">{t('chooseLocationMap')}</h3>
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">{t('clickMapSelectCoords')}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-all text-gray-400 hover:text-gray-650"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Map Body */}
|
|
<div className="flex-1 bg-gray-50 dark:bg-slate-950 relative min-h-[300px]" style={{ zIndex: 10 }}>
|
|
|
|
{/* Floating Search Panel */}
|
|
<div className="absolute top-4 left-4 right-4 sm:right-auto z-[1000] sm:w-80 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl border border-slate-150 dark:border-slate-800 shadow-xl p-2 flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 relative flex items-center">
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
placeholder={t('searchPlaceholder')}
|
|
className="w-full bg-slate-50 dark:bg-slate-800 border-0 outline-none rounded-xl pl-8 pr-3 py-2 text-xs text-slate-800 dark:text-slate-100"
|
|
/>
|
|
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5" />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleSearch}
|
|
disabled={isSearching}
|
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0 flex items-center gap-1"
|
|
>
|
|
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : t('confirm')}
|
|
</button>
|
|
</div>
|
|
|
|
{searchResults.length > 0 && (
|
|
<div className="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-slate-800/50 bg-white dark:bg-slate-900 rounded-xl border border-slate-150 dark:border-slate-800 shadow-inner">
|
|
{searchResults.map((r, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => handleSelectResult(r)}
|
|
className="w-full text-left px-3 py-2.5 text-[10px] text-gray-700 dark:text-slate-350 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors truncate block"
|
|
title={r.display_name}
|
|
>
|
|
{r.display_name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<MapContainer
|
|
center={position}
|
|
zoom={13}
|
|
attributionControl={false}
|
|
style={{ width: '100%', height: '100%', zIndex: 1 }}
|
|
>
|
|
<TileLayer
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
<MapClickEvents onClick={handleMapClick} />
|
|
<MapInvalidator />
|
|
<RecenterMap position={position} />
|
|
{hasSelected && (
|
|
<Marker position={position} icon={DefaultIcon} />
|
|
)}
|
|
</MapContainer>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
|
|
<div className="text-xs text-gray-500 dark:text-slate-400">
|
|
{hasSelected ? (
|
|
<span className="font-semibold text-gray-700 dark:text-slate-200">
|
|
{t('coordsLabel')}: {position[0].toFixed(6)}, {position[1].toFixed(6)}
|
|
</span>
|
|
) : (
|
|
<span className="italic text-gray-400 dark:text-slate-500">{t('noCoordsSelected')}</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 border border-gray-200 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800 text-gray-700 dark:text-slate-300 rounded-xl text-xs font-bold transition-all animate-fade-in"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleConfirm}
|
|
disabled={!hasSelected}
|
|
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 dark:disabled:bg-slate-800 dark:disabled:text-slate-650 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
|
|
>
|
|
{t('confirm')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|