Files
travelplanning/frontend/src/pages/ExploreMap.tsx
T

473 lines
21 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
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, MapPin, Loader2 } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal';
// Fix lỗi icon mặc định của Leaflet
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],
});
L.Marker.prototype.options.icon = DefaultIcon;
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
// Component Helper để đóng menu khi tương tác với bản đồ
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
useMapEvents({
click: () => onMapAction(),
movestart: onMapAction,
dragstart: onMapAction,
});
return null;
}
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
function MapTracker() {
const setMapCenter = useTourStore(state => state.setMapCenter);
useMapEvents({
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
const coords: [number, number] = [center.lat, center.lng];
setMapCenter(coords);
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
},
});
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const fetchTour = useTourStore(state => state.fetchTour);
const setMapCenter = useTourStore(state => state.setMapCenter);
const notify = useNotification();
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
const [initialViewState] = useState(() => {
const saved = localStorage.getItem('map_view_state');
if (saved) {
try { return JSON.parse(saved); } catch (e) { return null; }
}
return null;
});
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
const [mapZoom] = useState(initialViewState?.zoom || 13);
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
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 availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
// Tổng hợp nhãn từ danh sách Tour đang có để hiển thị bộ lọc đầy đủ (bao gồm cả nhãn tùy chỉnh)
const allFilterTags = React.useMemo(() => {
const tagsSet = new Set(availableTags);
publicTours.forEach(tour => {
tour.tags?.forEach((tag: string) => tagsSet.add(tag));
});
return Array.from(tagsSet);
}, [publicTours]);
// State cho menu chuột phải chia sẻ
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
const handleShare = (id: string, title: string) => {
const shareUrl = `${window.location.origin}?viewTour=${id}`;
if (navigator.share) {
navigator.share({
title: title,
text: `Khám phá hành trình du lịch: ${title}`,
url: shareUrl,
}).catch(() => {});
} else if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(shareUrl).then(() => {
notify({
title: 'Thành công',
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
type: 'success'
});
});
} else {
// Giải pháp dự phòng cho môi trường không có HTTPS
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
notify({
title: 'Thành công',
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
type: 'success'
});
} catch (err) {}
document.body.removeChild(textArea);
}
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));
}, [publicTours, selectedFilterTag]);
useEffect(() => {
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
if (!initialViewState) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPos(posArray);
setMapCenter(posArray);
},
() => console.log("Không thể lấy vị trí người dùng")
);
} else {
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
setMapCenter(initialViewState.center);
}
}, []);
return (
<div className="h-screen w-full relative">
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
<div className="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
<div className="flex items-center gap-3 pointer-events-auto">
<button
onClick={onBack}
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 lọc Tag và Dropdown */}
<div className="relative">
<button
onClick={() => setIsFilterDropdownOpen(prev => !prev)}
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center"
title="Lọc theo loại"
>
<Filter className="w-6 h-6 text-gray-800" />
</button>
{/* Filter Dropdown Content */}
{isFilterDropdownOpen && (
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200">
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
<Filter className="w-3.5 h-3.5 text-blue-600" />
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
</div>
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
<button
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
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'}`}
>
Tất cả
</button>
{allFilterTags.map(tag => (
<button
key={tag}
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
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>
{/* Search Box - Thay thế div "Khám phá khu vực" */}
<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">
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
<input
type="text"
placeholder="Tìm kiếm địa điểm, tour..."
className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium"
value={searchQuery}
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>
)}
{/* Dropdown danh sách gợi ý */}
{suggestions.length > 0 && (
<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">
{suggestions.map((s, idx) => (
<button
key={`${s.type}-${s.id}-${idx}`}
onClick={() => handleSelectSuggestion(s)}
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"
>
<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>
{/* Nhóm bên phải: Các thao tác người dùng */}
<div className="flex items-center gap-2 pointer-events-auto">
{/* Nút tạo Tour mới */}
{user && (
<button
onClick={() => setIsCreateModalOpen(true)}
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"
title="Tạo Tour mới"
>
<Navigation className="w-5 h-5" />
<span className="hidden md:inline text-sm">Tạo Tour</span>
</button>
)}
{/* Nút quản lý người dùng cho Admin */}
{user?.isAdmin && (
<button
onClick={() => setIsAdminModalOpen(true)}
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>
<MapContainer
center={userPos}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{/* Theo dõi di chuyển bản đồ */}
<MapTracker />
{/* Đóng menu và dropdown khi tương tác bản đồ */}
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
<MarkerClusterGroup chunkedLoading>
{filteredTours.map((tour) => {
const startLoc = tour.legs?.[0]?.locations?.[0];
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
const markerPos = startLoc
? [startLoc.latitude, startLoc.longitude] as [number, number]
: userPos;
return (
<Marker
key={tour.id}
position={markerPos}
eventHandlers={{
click: () => onViewTour(tour.id),
contextmenu: (e) => {
// Kiểm tra quyền chia sẻ (OWNER, MANAGER, MEMBER)
const role = tour.participants?.[0]?.role;
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(role);
// Hiển thị menu tại vị trí chuột
setShareMenu({
x: e.containerPoint.x,
y: e.containerPoint.y,
id: tour.id,
title: tour.title,
canShare
});
}
}}
icon={L.divIcon({
className: 'custom-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
</div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
S
</div>
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
>
<Tooltip direction="top" offset={[0, -20]} opacity={1}>
<div className="p-1 max-w-[180px]">
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
{tour.tags && tour.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1">
{tour.tags.map((tag: string) => (
<span key={tag} className="px-1.5 py-0.5 bg-blue-50 text-blue-500 rounded text-[8px] font-bold border border-blue-100">{tag}</span>
))}
</div>
)}
{tour.description && (
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
{tour.description}
</div>
)}
</div>
</Tooltip>
</Marker>
);
})}
</MarkerClusterGroup>
</MapContainer>
{/* Context Menu Chia sẻ */}
{shareMenu && (
<div
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: shareMenu.y, left: shareMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{shareMenu.canShare ? (
<button
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
>
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
</button>
) : (
<div className="px-4 py-2 text-xs text-gray-400 italic">Bạn không quyền chia sẻ tour này</div>
)}
</div>
)}
{/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
{/* Create Tour Modal */}
<CreateTourModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={(tour) => {
fetchTour(tour.id);
onViewTour(tour.id);
}}
/>
</div>
);
};