feat: thêm nút tìm kiếm địa điểm cho phần tạo tour, phần thêm địa điểm
This commit is contained in:
@@ -316,8 +316,61 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mini Map Picker */}
|
{/* Mini Map Picker */}
|
||||||
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
<div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
|
||||||
<MapContainer center={currentCoords} zoom={13} className="h-full w-full">
|
{/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
|
||||||
|
<div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
|
||||||
|
<div className="relative max-w-sm pointer-events-auto">
|
||||||
|
<div className="relative group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm địa điểm trên bản đồ..."
|
||||||
|
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-gray-800"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => handleSearchLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
|
||||||
|
) : formData.name && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */}
|
||||||
|
{(searchResults.length > 0 || hasNoResults) && (
|
||||||
|
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||||
|
{hasNoResults ? (
|
||||||
|
<div className="px-4 py-4 text-center text-gray-400 text-xs italic">Không tìm thấy địa điểm phù hợp...</div>
|
||||||
|
) : (
|
||||||
|
searchResults.map((result, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectSearchResult(result)}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
|
||||||
|
{result.type && (
|
||||||
|
<span className="text-[8px] font-black uppercase text-blue-400 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100 shrink-0">{result.type}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false}>
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
<Marker position={currentCoords} />
|
<Marker position={currentCoords} />
|
||||||
<MapPicker center={currentCoords} onPick={handlePickLocation} />
|
<MapPicker center={currentCoords} onPick={handlePickLocation} />
|
||||||
@@ -339,57 +392,15 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="relative">
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||||
<div className="relative group">
|
<input
|
||||||
<input
|
required
|
||||||
required
|
placeholder="Tên địa điểm..."
|
||||||
placeholder="Gõ để tìm kiếm địa điểm..."
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
|
||||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
value={formData.name}
|
||||||
value={formData.name}
|
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||||
onChange={e => handleSearchLocation(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
|
||||||
{isSearching ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
|
||||||
) : formData.name ? (
|
|
||||||
<button type="button" onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }} className="hover:text-red-500 transition-colors">
|
|
||||||
<X className="w-4 h-4 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Search className="w-4 h-4 text-gray-300" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(searchResults.length > 0 || hasNoResults) && (
|
|
||||||
<div className="absolute z-[5000] left-0 right-0 mt-2 bg-white border border-gray-200 rounded-2xl shadow-2xl overflow-hidden max-h-64 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
|
||||||
{hasNoResults ? (
|
|
||||||
<div className="px-4 py-6 text-center text-gray-400 text-sm italic">
|
|
||||||
Không tìm thấy địa điểm nào phù hợp...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
searchResults.map((result, idx) => (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); selectSearchResult(result); }}
|
|
||||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-all flex flex-col gap-0.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="font-bold text-sm text-gray-900 line-clamp-1">
|
|
||||||
{result.namedetails?.name || result.display_name.split(',')[0]}
|
|
||||||
</div>
|
|
||||||
{result.type && (
|
|
||||||
<span className="text-[9px] font-black uppercase text-blue-400 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100 shrink-0">{result.type}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight">{result.display_name}</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
List,
|
List,
|
||||||
Map as MapIconLucide,
|
Map as MapIconLucide,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
@@ -198,6 +200,29 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
const [commentLocationName, setCommentLocationName] = useState('');
|
const [commentLocationName, setCommentLocationName] = useState('');
|
||||||
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
const handleSearchLocation = async (query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (query.trim().length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1&namedetails=1&accept-language=vi`);
|
||||||
|
const data = await res.json();
|
||||||
|
setSearchResults(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Lỗi tìm kiếm:", e);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
||||||
|
|
||||||
@@ -803,6 +828,53 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
}} isPublicView={isPublicView} />
|
}} isPublicView={isPublicView} />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||||
|
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
|
||||||
|
{!isPublicView && (
|
||||||
|
<div className="absolute top-4 right-4 z-[1001] w-64 md:w-80">
|
||||||
|
<div className="relative group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm địa điểm để ghim..."
|
||||||
|
className="w-full pl-10 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => handleSearchLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
|
||||||
|
) : searchQuery && (
|
||||||
|
<button onClick={() => { setSearchQuery(''); setSearchResults([]); }} className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-red-500 transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kết quả tìm kiếm */}
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-48 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||||
|
{searchResults.map((result, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => {
|
||||||
|
setMapCenter([parseFloat(result.lat), parseFloat(result.lon)]);
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchQuery('');
|
||||||
|
notify({
|
||||||
|
title: 'Tìm thấy địa điểm',
|
||||||
|
message: `Đã di chuyển bản đồ tới: ${result.namedetails?.name || result.display_name.split(',')[0]}`,
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
|
||||||
|
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={initialViewState?.center || mapCenter}
|
center={initialViewState?.center || mapCenter}
|
||||||
zoom={mapZoom}
|
zoom={mapZoom}
|
||||||
|
|||||||
Reference in New Issue
Block a user