feat: Chuyển khám phá địa điểm thành box tìm kiếm địa điểm trên bản đồ
This commit is contained in:
@@ -5,7 +5,7 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon, MapPin, Loader2 } from 'lucide-react';
|
||||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { CreateTourModal } from '../components/CreateTourModal';
|
import { CreateTourModal } from '../components/CreateTourModal';
|
||||||
@@ -31,7 +31,7 @@ function RecenterMap({ position }: { position: [number, number] }) {
|
|||||||
// Component Helper để đóng menu khi tương tác với bản đồ
|
// Component Helper để đóng menu khi tương tác với bản đồ
|
||||||
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
|
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
click: onMapAction,
|
click: () => onMapAction(),
|
||||||
movestart: onMapAction,
|
movestart: onMapAction,
|
||||||
dragstart: onMapAction,
|
dragstart: onMapAction,
|
||||||
});
|
});
|
||||||
@@ -78,6 +78,47 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
|
||||||
|
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
|
||||||
|
const [isSearchingSuggestions, setIsSearchingSuggestions] = useState(false);
|
||||||
|
|
||||||
|
// Logic xử lý gợi ý tự động khi người dùng gõ
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
if (searchQuery.trim().length < 2) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearchingSuggestions(true);
|
||||||
|
try {
|
||||||
|
// 1. Lọc các Tour hiện có khớp với từ khóa
|
||||||
|
const tourMatches = publicTours
|
||||||
|
.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
.map(t => ({ type: 'tour' as const, id: t.id, name: t.title }));
|
||||||
|
|
||||||
|
// 2. Tìm kiếm địa điểm thực tế trên bản đồ qua OpenStreetMap
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5&addressdetails=1&accept-language=vi`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const locationMatches = data.map((item: any) => ({
|
||||||
|
type: 'location' as const,
|
||||||
|
id: item.place_id,
|
||||||
|
name: item.display_name,
|
||||||
|
lat: parseFloat(item.lat),
|
||||||
|
lon: parseFloat(item.lon)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Hợp nhất kết quả: Tour ưu tiên lên đầu
|
||||||
|
setSuggestions([...tourMatches, ...locationMatches]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Lỗi tìm kiếm gợi ý:", err);
|
||||||
|
} finally {
|
||||||
|
setIsSearchingSuggestions(false);
|
||||||
|
}
|
||||||
|
}, 500); // Debounce 500ms để tránh gọi API quá nhiều
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, publicTours]);
|
||||||
|
|
||||||
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
|
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
|
||||||
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
||||||
@@ -129,6 +170,23 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
setShareMenu(null);
|
setShareMenu(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectSuggestion = (s: any) => {
|
||||||
|
if (s.type === 'tour') {
|
||||||
|
onViewTour(s.id);
|
||||||
|
} else if (s.lat && s.lon) {
|
||||||
|
const pos: [number, number] = [s.lat, s.lon];
|
||||||
|
setUserPos(pos);
|
||||||
|
setMapCenter(pos);
|
||||||
|
notify({
|
||||||
|
title: 'Tìm thấy địa điểm',
|
||||||
|
message: `Đã di chuyển bản đồ tới: ${s.name.split(',')[0]}`,
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSuggestions([]);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
const filteredTours = React.useMemo(() => {
|
const filteredTours = React.useMemo(() => {
|
||||||
if (!selectedFilterTag) return publicTours;
|
if (!selectedFilterTag) return publicTours;
|
||||||
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
|
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
|
||||||
@@ -158,79 +216,119 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full relative">
|
<div className="h-screen w-full relative">
|
||||||
{/* Nút quay lại */}
|
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
|
||||||
<button
|
<div className="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
|
||||||
onClick={onBack}
|
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
|
||||||
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
|
<div className="flex items-center gap-3 pointer-events-auto">
|
||||||
>
|
<button
|
||||||
<X className="w-6 h-6 text-gray-800" />
|
onClick={onBack}
|
||||||
</button>
|
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100"
|
||||||
|
title="Quay lại"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6 text-gray-800" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
|
{/* Search Box - Thay thế div "Khám phá khu vực" */}
|
||||||
{onLogout && (
|
<div className="relative flex items-center bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
|
||||||
<button
|
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
|
||||||
onClick={onLogout}
|
<input
|
||||||
className="absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700"
|
type="text"
|
||||||
>
|
placeholder="Tìm kiếm địa điểm, tour..."
|
||||||
<LogOut className="w-5 h-5" />
|
className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium"
|
||||||
<span className="hidden sm:inline">Đăng xuất</span>
|
value={searchQuery}
|
||||||
</button>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
)}
|
/>
|
||||||
|
{isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />}
|
||||||
|
{searchQuery && (
|
||||||
|
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-gray-400 hover:text-gray-600 rounded-full">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Nút quản lý người dùng cho Admin */}
|
{/* Dropdown danh sách gợi ý */}
|
||||||
{user?.isAdmin && (
|
{suggestions.length > 0 && (
|
||||||
<button
|
<div className="absolute top-full left-0 right-0 mt-3 bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
|
||||||
onClick={() => setIsAdminModalOpen(true)}
|
{suggestions.map((s, idx) => (
|
||||||
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
|
<button
|
||||||
>
|
key={`${s.type}-${s.id}-${idx}`}
|
||||||
<Settings className="w-5 h-5" />
|
onClick={() => handleSelectSuggestion(s)}
|
||||||
<span className="hidden sm:inline">Quản lý hệ thống</span>
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 flex items-center gap-3 transition-colors border-b border-gray-50 last:border-0"
|
||||||
</button>
|
>
|
||||||
)}
|
<div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}>
|
||||||
|
{s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-bold text-gray-800 truncate">{s.name}</span>
|
||||||
|
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
|
||||||
|
{s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Nút tạo Tour mới */}
|
{/* Nhóm bên phải: Các thao tác người dùng */}
|
||||||
{user && (
|
<div className="flex items-center gap-2 pointer-events-auto">
|
||||||
<button
|
{/* Nút tạo Tour mới */}
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
{user && (
|
||||||
className="absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
|
<button
|
||||||
>
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
<Navigation className="w-5 h-5" />
|
className="bg-green-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||||
<span className="hidden sm:inline">Tạo Tour mới</span>
|
title="Tạo Tour mới"
|
||||||
</button>
|
>
|
||||||
)}
|
<Navigation className="w-5 h-5" />
|
||||||
|
<span className="hidden md:inline text-sm">Tạo Tour</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header Overlay */}
|
{/* Nút quản lý người dùng cho Admin */}
|
||||||
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
{user?.isAdmin && (
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<Navigation className="w-4 h-4 text-blue-600" />
|
onClick={() => setIsAdminModalOpen(true)}
|
||||||
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
|
className="bg-blue-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||||
|
title="Quản lý hệ thống"
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
<span className="hidden md:inline text-sm">Hệ thống</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nút đăng xuất */}
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700 border border-gray-100"
|
||||||
|
title="Đăng xuất"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
<span className="hidden md:inline text-sm">Rời đi</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bộ lọc theo Tag */}
|
{/* Ribbon lọc theo Tag - Đặt ngay dưới top-bar, có thể cuộn ngang */}
|
||||||
<div className="absolute top-24 left-6 z-[1000] flex flex-col gap-2 pointer-events-none">
|
<div className="absolute top-[72px] left-0 right-0 z-[1001] px-4 py-2 bg-white/90 backdrop-blur-md shadow-md border-b border-gray-100 pointer-events-none">
|
||||||
<div className="bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 pointer-events-auto flex flex-col gap-2 max-w-[200px]">
|
<div className="flex items-center gap-2 overflow-x-auto whitespace-nowrap scrollbar-hide pointer-events-auto">
|
||||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
<Filter className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
<button
|
||||||
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
|
onClick={() => setSelectedFilterTag(null)}
|
||||||
</div>
|
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all flex-shrink-0 ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
>
|
||||||
<button
|
Tất cả
|
||||||
onClick={() => setSelectedFilterTag(null)}
|
</button>
|
||||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
{allFilterTags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => setSelectedFilterTag(tag === selectedFilterTag ? null : tag)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all flex-shrink-0 ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||||
>
|
>
|
||||||
Tất cả
|
{tag}
|
||||||
</button>
|
</button>
|
||||||
{allFilterTags.map(tag => (
|
))}
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
onClick={() => setSelectedFilterTag(tag === selectedFilterTag ? null : tag)}
|
|
||||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,7 +347,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
<MapTracker />
|
<MapTracker />
|
||||||
|
|
||||||
{/* Đóng menu khi tương tác bản đồ */}
|
{/* Đóng menu khi tương tác bản đồ */}
|
||||||
<MapEvents onMapAction={() => setShareMenu(null)} />
|
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); }} />
|
||||||
|
|
||||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||||
<RecenterMap position={userPos} />
|
<RecenterMap position={userPos} />
|
||||||
|
|||||||
Reference in New Issue
Block a user