359 lines
15 KiB
TypeScript
359 lines
15 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 } from 'lucide-react';
|
|
import { UserManagementModal } from '@/components/UserManagementModal';
|
|
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
|
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 notificationModal = useNotificationModal();
|
|
|
|
// 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 [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(() => {
|
|
notificationModal.openModal('Thành công', 'Đã 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!', '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');
|
|
notificationModal.openModal('Thành công', 'Đã 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!', 'success');
|
|
} catch (err) {}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
setShareMenu(null);
|
|
};
|
|
|
|
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">
|
|
{/* Nút quay lại */}
|
|
<button
|
|
onClick={onBack}
|
|
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
|
|
>
|
|
<X className="w-6 h-6 text-gray-800" />
|
|
</button>
|
|
|
|
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
|
|
{onLogout && (
|
|
<button
|
|
onClick={onLogout}
|
|
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"
|
|
>
|
|
<LogOut className="w-5 h-5" />
|
|
<span className="hidden sm:inline">Đăng xuất</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Nút quản lý người dùng cho Admin */}
|
|
{user?.isAdmin && (
|
|
<button
|
|
onClick={() => setIsAdminModalOpen(true)}
|
|
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"
|
|
>
|
|
<Settings className="w-5 h-5" />
|
|
<span className="hidden sm:inline">Quản lý hệ thống</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Nút tạo Tour mới */}
|
|
{user && (
|
|
<button
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
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"
|
|
>
|
|
<Navigation className="w-5 h-5" />
|
|
<span className="hidden sm:inline">Tạo Tour mới</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Header Overlay */}
|
|
<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">
|
|
<div className="flex items-center gap-2">
|
|
<Navigation className="w-4 h-4 text-blue-600" />
|
|
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bộ lọc theo Tag */}
|
|
<div className="absolute top-24 left-6 z-[1000] flex flex-col gap-2 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 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-wrap gap-1.5">
|
|
<button
|
|
onClick={() => setSelectedFilterTag(null)}
|
|
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)}
|
|
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>
|
|
|
|
<MapContainer
|
|
center={userPos}
|
|
zoom={mapZoom}
|
|
className="h-full w-full"
|
|
preferCanvas={true}
|
|
>
|
|
<TileLayer
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
attribution='© OpenStreetMap contributors'
|
|
/>
|
|
|
|
{/* Theo dõi di chuyển bản đồ */}
|
|
<MapTracker />
|
|
|
|
{/* Đóng menu khi tương tác bản đồ */}
|
|
<MapEvents onMapAction={() => setShareMenu(null)} />
|
|
|
|
{/* 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 có 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);
|
|
}}
|
|
/>
|
|
|
|
<NotificationModal
|
|
isOpen={notificationModal.modalState?.isOpen ?? false}
|
|
title={notificationModal.modalState?.title}
|
|
message={notificationModal.modalState?.message}
|
|
type={notificationModal.modalState?.type}
|
|
onConfirm={() => notificationModal.closeModal()}
|
|
/>
|
|
</div>
|
|
);
|
|
}; |