From c005009da2c04b76d80e5d9e5ed6cd356a19cb66 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 16 Jun 2026 15:28:10 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Chuy=E1=BB=83n=20kh=C3=A1m=20ph=C3=A1?= =?UTF-8?q?=20=C4=91=E1=BB=8Ba=20=C4=91i=E1=BB=83m=20th=C3=A0nh=20box=20t?= =?UTF-8?q?=C3=ACm=20ki=E1=BA=BFm=20=C4=91=E1=BB=8Ba=20=C4=91i=E1=BB=83m?= =?UTF-8?q?=20tr=C3=AAn=20b=E1=BA=A3n=20=C4=91=E1=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/ExploreMap.tsx | 232 +++++++++++++++++++++--------- 1 file changed, 165 insertions(+), 67 deletions(-) diff --git a/frontend/src/pages/ExploreMap.tsx b/frontend/src/pages/ExploreMap.tsx index 53481e7..4e1208e 100644 --- a/frontend/src/pages/ExploreMap.tsx +++ b/frontend/src/pages/ExploreMap.tsx @@ -5,7 +5,7 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; 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 { useNotification } from '@/hooks/useNotification'; 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 đồ function MapEvents({ onMapAction }: { onMapAction: () => void }) { useMapEvents({ - click: onMapAction, + click: () => onMapAction(), movestart: onMapAction, dragstart: onMapAction, }); @@ -78,6 +78,47 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () const [mapZoom] = useState(initialViewState?.zoom || 13); const [isAdminModalOpen, setIsAdminModalOpen] = 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(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']; @@ -129,6 +170,23 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () 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(() => { if (!selectedFilterTag) return publicTours; return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag)); @@ -158,79 +216,119 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () return (
- {/* 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 */} +
+ {/* Nhóm bên trái: Quay lại và Thông tin vị trí */} +
+ - {/* Nút đăng xuất - Chỉ hiển thị khi có user login */} - {onLogout && ( - - )} + {/* Search Box - Thay thế div "Khám phá khu vực" */} +
+ + setSearchQuery(e.target.value)} + /> + {isSearchingSuggestions && } + {searchQuery && ( + + )} - {/* Nút quản lý người dùng cho Admin */} - {user?.isAdmin && ( - - )} + {/* Dropdown danh sách gợi ý */} + {suggestions.length > 0 && ( +
+ {suggestions.map((s, idx) => ( + + ))} +
+ )} +
+
- {/* Nút tạo Tour mới */} - {user && ( - - )} + {/* Nhóm bên phải: Các thao tác người dùng */} +
+ {/* Nút tạo Tour mới */} + {user && ( + + )} - {/* Header Overlay */} -
-
- - Đang khám phá khu vực của bạn + {/* Nút quản lý người dùng cho Admin */} + {user?.isAdmin && ( + + )} + + {/* Nút đăng xuất */} + {onLogout && ( + + )}
- {/* Bộ lọc theo Tag */} -
-
-
- - Lọc theo loại -
-
- + {allFilterTags.map(tag => ( + - {allFilterTags.map(tag => ( - - ))} -
+ ))}
@@ -249,7 +347,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () {/* Đóng menu khi tương tác bản đồ */} - setShareMenu(null)} /> + { setShareMenu(null); setSuggestions([]); }} /> {/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}