import React, { useEffect, useState } from 'react'; import { MapContainer, TileLayer, Marker, 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, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users, ShieldAlert, Star } from 'lucide-react'; import { UserManagementModal } from '@/components/UserManagementModal'; import { ReportBusinessModal } from '../components/ReportBusinessModal'; import { useNotification } from '@/hooks/useNotification'; import { useConfirm } from '@/hooks/useConfirm'; import { CreateTourModal } from '../components/CreateTourModal'; import { useTranslation } from '@/hooks/useTranslation'; import { useTheme } from '@/hooks/useTheme'; import { PublicPhotoModal } from '../components/PublicPhotoModal'; // 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; // Tag ID to Label Mapping for Public Photos const PHOTO_TAG_LABELS: { [key: string]: string } = { 'phong-canh': '🏞️ Phong cảnh', 'con-nguoi': '👥 Con người', 'doi-thuong': '🎒 Đời thường', 'bien': '🌊 Biển', 'nui': '⛰️ Núi', 'do-thi': '🏙️ Đô thị', 'thuc-an': '🍜 Thức ăn', 'cho': '🛍️ Chợ', 'hien-dai': '🏗️ Hiện đại', 'dong-vat': '🦁 Động vật', 'thu-cung': '🐕 Thú cưng' }; // 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, onOpenMyPhotos, onLoginSuccess, onGoToDashboard }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: () => void }) => { // Check if user is logged in (real user) or is a guest const guestToken = localStorage.getItem('guest_token'); const isLoggedInOrGuest = user || guestToken; // 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(); const confirm = useConfirm(); const { t, lang, changeLanguage } = useTranslation(); const { theme, changeTheme } = useTheme(); const handlePromoteAdmin = async (secretKey: string) => { try { const response = await fetch('/api/v1/auth/promote-admin', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ secretKey }) }); const data = await response.json(); if (response.ok && data.success) { const updatedUser = { ...user, isAdmin: true }; localStorage.setItem('user', JSON.stringify(updatedUser)); if (onLoginSuccess) { onLoginSuccess(updatedUser); } notify({ title: t('success'), message: 'Đã kích hoạt quyền quản trị thành công!', type: 'success' }); setIsAdminModalOpen(true); } else { notify({ title: t('error'), message: data.message || t('invalidSecretKey'), type: 'error' }); } } catch (err) { console.error(err); notify({ title: t('error'), message: 'Lỗi mạng khi kích hoạt Admin.', type: 'error' }); } }; // Refs for mobile long-press detection const touchTimerRef = React.useRef(null); const touchMovedRef = React.useRef(false); // Phân loại trạng thái Tour dựa vào thời gian const getTourStatus = (tour: any) => { const now = new Date(); const startDate = tour.startDate ? new Date(tour.startDate) : null; const endDate = tour.endDate ? new Date(tour.endDate) : null; if (endDate && endDate < now) { return { color: 'white', borderClass: 'border-white', label: 'Hành trình đã kết thúc' }; } if (startDate && endDate && startDate <= now && endDate >= now) { return { color: 'red', borderClass: 'border-rose-500', label: 'Hành trình đang diễn ra' }; } return { color: 'green', borderClass: 'border-emerald-500', label: 'Hành trình mới tạo' }; }; const triggerJoinConfirmation = async (tour: any) => { const currentUserId = user?.id; const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId); if (myParticipant) { notify({ title: 'Thông báo', message: 'Bạn đã là thành viên của hành trình này.', type: 'info' }); return; } const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0; if (hasPendingRequest) { notify({ title: 'Thông báo', message: 'Bạn đã gửi yêu cầu tham gia hành trình này và đang chờ duyệt.', type: 'info' }); return; } const isConfirmed = await confirm({ title: 'Yêu cầu tham gia Tour', message: `Bạn có chắc chắn muốn gửi yêu cầu tham gia vào tour "${tour.title}" không?` }); if (isConfirmed) { try { const res = await fetch(`/api/v1/tours/${tour.id}/join-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` } }); const data = await res.json(); if (!res.ok) { throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.'); } notify({ title: 'Thành công', message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.', type: 'success' }); fetchPublicTours(); } catch (err: any) { notify({ title: 'Lỗi', message: err.message || 'Không thể gửi yêu cầu tham gia.', type: 'error' }); } } }; // 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 [publicPhotos, setPublicPhotos] = useState([]); const [selectedPhoto, setSelectedPhoto] = useState(null); const [selectedPhotoGroup, setSelectedPhotoGroup] = useState([]); const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState([]); const groupedPhotos = React.useMemo(() => { // Filter photos by selected tags if any are selected let filteredPhotos = publicPhotos; if (selectedPhotoFilterTags.length > 0) { filteredPhotos = publicPhotos.filter((photo) => { const photoTags = photo.metadata?.tags as string[] | undefined; if (!Array.isArray(photoTags)) return false; // Check if photo has at least one of the selected tags return selectedPhotoFilterTags.some(tag => photoTags.includes(tag)); }); } const groups: { [key: string]: any[] } = {}; filteredPhotos.forEach((photo) => { const lat = photo.metadata?.lat; const lng = photo.metadata?.lng; if (typeof lat === 'number' && typeof lng === 'number') { const key = `${lat.toFixed(5)},${lng.toFixed(5)}`; if (!groups[key]) { groups[key] = []; } groups[key].push(photo); } }); // Sort each group by likes descending Object.values(groups).forEach((group) => { group.sort((a, b) => { const likesA = a.metadata?.likedUserIds?.length || 0; const likesB = b.metadata?.likedUserIds?.length || 0; return likesB - likesA; }); }); return Object.values(groups); }, [publicPhotos, selectedPhotoFilterTags]); const fetchPublicPhotos = async () => { try { const response = await fetch('/api/v1/public-photos'); if (response.ok) { const data = await response.json(); setPublicPhotos(data); } } catch (error) { console.error('Error fetching public photos:', error); } }; const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [trustedUsers, setTrustedUsers] = useState([]); const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false); const [blacklist, setBlacklist] = useState([]); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [isBlacklistOpen, setIsBlacklistOpen] = useState(false); const mapCenter = useTourStore(state => state.mapCenter); // Recommendations and GPS States const [recommendedLocations, setRecommendedLocations] = useState([]); const [isRecommendedOpen, setIsRecommendedOpen] = useState(false); const [isProposeModalOpen, setIsProposeModalOpen] = useState(false); const [sortBlacklistByDistance, setSortBlacklistByDistance] = useState(false); const [sortRecsByDistance, setSortRecsByDistance] = useState(false); const [userGpsPos, setUserGpsPos] = useState<[number, number] | null>(null); const [proposeForm, setProposeForm] = useState({ name: '', type: 'RESTAURANT', phone: '', email: '', address: '', latitude: '', longitude: '', description: '', stars: 5 }); const fetchBlacklist = async () => { try { const res = await fetch('/api/v1/reports/blacklist'); if (res.ok) { const data = await res.json(); setBlacklist(data); } } catch (e) { console.error('Lỗi khi tải blacklist:', e); } }; const fetchRecommendations = async () => { try { const res = await fetch('/api/v1/recommendations'); if (res.ok) { const data = await res.json(); setRecommendedLocations(data); } } catch (e) { console.error('Lỗi khi tải danh sách đề xuất:', e); } }; const fetchTrustedUsers = async () => { try { const res = await fetch('/api/v1/users/trusted'); if (res.ok) { const data = await res.json(); setTrustedUsers(data); } } catch (e) { console.error('Lỗi khi tải thành viên uy tín:', e); } }; const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => { const R = 6371; // Earth radius in km const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }; const requestGpsPosition = () => { if (!navigator.geolocation) { alert("Trình duyệt không hỗ trợ định vị GPS."); return; } navigator.geolocation.getCurrentPosition( (pos) => { const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude]; setUserGpsPos(posArray); setUserPos(posArray); setMapCenter(posArray); }, () => { alert("Không thể truy cập vị trí của bạn. Vui lòng cho phép quyền truy cập vị trí."); } ); }; const handleProposeSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!proposeForm.name || !proposeForm.description) { alert("Vui lòng điền tên địa điểm và mô tả."); return; } const token = localStorage.getItem('token'); if (!token) { alert("Bạn cần đăng nhập để gửi đề xuất địa điểm."); return; } try { const res = await fetch('/api/v1/recommendations', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ ...proposeForm, latitude: parseFloat(proposeForm.latitude), longitude: parseFloat(proposeForm.longitude) }) }); if (res.ok) { alert("Đề xuất của bạn đã được gửi thành công và đang chờ Admin phê duyệt!"); setIsProposeModalOpen(false); setProposeForm({ name: '', type: 'RESTAURANT', phone: '', email: '', address: '', latitude: userPos[0].toString(), longitude: userPos[1].toString(), description: '', stars: 5 }); fetchRecommendations(); } else { const errData = await res.json(); alert(errData.message || "Gửi đề xuất thất bại."); } } catch (err) { alert("Đã xảy ra lỗi kết nối."); } }; const processedBlacklist = React.useMemo(() => { if (!sortBlacklistByDistance || !userGpsPos) return blacklist; return [...blacklist].sort((a, b) => { if (typeof a.latitude !== 'number' || typeof a.longitude !== 'number') return 1; if (typeof b.latitude !== 'number' || typeof b.longitude !== 'number') return -1; const distA = calculateDistance(userGpsPos[0], userGpsPos[1], a.latitude, a.longitude); const distB = calculateDistance(userGpsPos[0], userGpsPos[1], b.latitude, b.longitude); return distA - distB; }); }, [blacklist, sortBlacklistByDistance, userGpsPos]); const processedRecommendations = React.useMemo(() => { if (!sortRecsByDistance || !userGpsPos) return recommendedLocations; return [...recommendedLocations].sort((a, b) => { if (typeof a.latitude !== 'number' || typeof a.longitude !== 'number') return 1; if (typeof b.latitude !== 'number' || typeof b.longitude !== 'number') return -1; const distA = calculateDistance(userGpsPos[0], userGpsPos[1], a.latitude, a.longitude); const distB = calculateDistance(userGpsPos[0], userGpsPos[1], b.latitude, b.longitude); return distA - distB; }); }, [recommendedLocations, sortRecsByDistance, userGpsPos]); useEffect(() => { if (isProposeModalOpen) { setProposeForm(prev => ({ ...prev, latitude: userPos[0].toString(), longitude: userPos[1].toString() })); } }, [isProposeModalOpen, userPos]); useEffect(() => { fetchTrustedUsers(); fetchBlacklist(); fetchRecommendations(); }, []); 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(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]); // Tổng hợp nhãn từ danh sách ảnh công khai để lọc ảnh const availablePhotoTags = React.useMemo(() => { const tagsSet = new Set(); publicPhotos.forEach(photo => { const tags = photo.metadata?.tags as string[] | undefined; if (Array.isArray(tags)) { tags.forEach(tag => tagsSet.add(tag)); } }); return Array.from(tagsSet).sort(); }, [publicPhotos]); // State cho menu chuột phải chia sẻ const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: 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 handleRequestJoin = async (tourId: string) => { setShareMenu(null); try { const res = await fetch(`/api/v1/tours/${tourId}/join-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` } }); const data = await res.json(); if (!res.ok) { throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.'); } notify({ title: 'Thành công', message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.', type: 'success' }); fetchPublicTours(); } catch (err: any) { notify({ title: 'Lỗi', message: err.message || 'Không thể gửi yêu cầu tham gia.', type: 'error' }); } }; 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(() => { // Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người fetchPublicPhotos(); // Chỉ tải danh sách tour 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 { setMapCenter(initialViewState.center); } }, []); useEffect(() => { const params = new URLSearchParams(window.location.search); const photoId = params.get('photoId'); if (photoId && publicPhotos.length > 0) { const foundPhoto = publicPhotos.find((p) => p.id === photoId); if (foundPhoto) { const lat = foundPhoto.metadata?.lat; const lng = foundPhoto.metadata?.lng; if (typeof lat === 'number' && typeof lng === 'number') { const group = publicPhotos.filter((p) => { const pLat = p.metadata?.lat; const pLng = p.metadata?.lng; return typeof pLat === 'number' && typeof pLng === 'number' && Math.abs(pLat - lat) < 0.00001 && Math.abs(pLng - lng) < 0.00001; }); group.sort((a, b) => { const likesA = a.metadata?.likedUserIds?.length || 0; const likesB = b.metadata?.likedUserIds?.length || 0; return likesB - likesA; }); setSelectedPhoto(foundPhoto); setSelectedPhotoGroup(group); } else { setSelectedPhoto(foundPhoto); setSelectedPhotoGroup([foundPhoto]); } } } }, [publicPhotos]); return (
{/* 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 lọc Tag và Dropdown */}
{/* Filter Dropdown Content */} {isFilterDropdownOpen && (
{/* Tour Filter Section */}
🧳 Chuyến đi
{allFilterTags.map(tag => ( ))}
{/* Photo Filter Section */} {availablePhotoTags.length > 0 && (
📸 Ảnh công khai
{availablePhotoTags.map(tagId => { const tagLabel = PHOTO_TAG_LABELS[tagId] || tagId; return ( ); })}
)}
)}
{/* Search Box - Thay thế div "Khám phá khu vực" */}
setSearchQuery(e.target.value)} /> {isSearchingSuggestions && } {searchQuery && ( )} {/* Dropdown danh sách gợi ý */} {suggestions.length > 0 && (
{suggestions.map((s, idx) => ( ))}
)}
{/* Nhóm bên phải: Các thao tác người dùng */}
{/* Nút Ảnh của tôi */} {isLoggedInOrGuest && ( )} {/* Nút tạo Tour mới */} {user && !localStorage.getItem('guest_token') && ( )} {/* Nút Báo cáo sai phạm */} {/* Lựa chọn Ngôn ngữ */}
{/* Lựa chọn Giao diện */}
{/* Nút quản lý người dùng cho Admin */} {user?.isAdmin && ( )} {/* Nút Bảng điều khiển của tôi - chỉ hiển thị cho người dùng đã đăng nhập (không phải khách) */} {user && !localStorage.getItem('guest_token') && onGoToDashboard && ( )} {/* Nút đăng xuất */} {onLogout && ( )}
{/* Theo dõi di chuyển bản đồ */} {/* Đóng menu và dropdown khi tương tác bản đồ */} { 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 độ */} {filteredTours.map((tour) => { let startLoc = null; if (tour.legs && tour.legs.length > 0) { for (const leg of tour.legs) { if (leg.locations && leg.locations.length > 0) { startLoc = leg.locations[0]; break; } } } 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; const status = getTourStatus(tour); return ( { if (status.color === 'green') { notify({ title: 'Thông báo', message: 'Đây là hành trình mới tạo. Vui lòng nhấn chuột phải (hoặc nhấn giữ trên màn hình điện thoại) để gửi yêu cầu tham gia.', type: 'info' }); } else { onViewTour(tour.id); } }, contextmenu: (e: any) => { if (status.color === 'green') { triggerJoinConfirmation(tour); } else { const currentUserId = user?.id; const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId); const isParticipant = !!myParticipant; const myRole = myParticipant?.role; const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole); const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0; // 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, isParticipant, hasPendingRequest }); } }, touchstart: () => { if (status.color === 'green') { if (touchTimerRef.current) { clearTimeout(touchTimerRef.current); } touchMovedRef.current = false; touchTimerRef.current = setTimeout(() => { if (!touchMovedRef.current) { triggerJoinConfirmation(tour); } }, 700); } }, touchend: () => { if (touchTimerRef.current) { clearTimeout(touchTimerRef.current); touchTimerRef.current = null; } }, touchmove: () => { touchMovedRef.current = true; if (touchTimerRef.current) { clearTimeout(touchTimerRef.current); touchTimerRef.current = null; } } } as any} icon={L.divIcon({ className: 'custom-bubble', html: `
S
`, iconSize: [56, 56], iconAnchor: [28, 28] })} >
{tour.title}
{tour.tags && tour.tags.length > 0 && (
{tour.tags.map((tag: string) => ( {tag} ))}
)} {tour.description && (
{tour.description}
)}
{status.label}
); })}
{groupedPhotos.map((photoGroup) => { const latestPhoto = photoGroup[0]; const lat = latestPhoto.metadata?.lat; const lng = latestPhoto.metadata?.lng; if (typeof lat !== 'number' || typeof lng !== 'number') return null; return ( { setSelectedPhoto(latestPhoto); setSelectedPhotoGroup(photoGroup); const params = new URLSearchParams(window.location.search); params.set('photoId', latestPhoto.id); window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`); } }} icon={L.divIcon({ className: 'custom-photo-bubble', html: `
📸
${photoGroup.length > 1 ? `
${photoGroup.length}
` : ''}
`, iconSize: [48, 48], iconAnchor: [24, 24] })} /> ); })} {/* Render blacklist markers */} {blacklist.map((item) => { if (typeof item.latitude !== 'number' || typeof item.longitude !== 'number') return null; return (
`, className: 'custom-blacklist-marker', iconSize: [32, 32], iconAnchor: [16, 16], })} >
⚠️ Blacklist: {item.name}
{item.type}
{item.address && (
{item.address}
)} {item.phone && (
SĐT: {item.phone}
)}
Lý do: {item.reason}
); })} {/* Render recommended markers */} {recommendedLocations.map((item) => { if (typeof item.latitude !== 'number' || typeof item.longitude !== 'number') return null; return ( `, className: 'custom-recommended-marker', iconSize: [32, 32], iconAnchor: [16, 16], })} >
🌟 {item.name}
{Array.from({ length: item.stars }).map((_, i) => ( ))}
{item.type === 'RESTAURANT' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
{item.address && (
{item.address}
)} {item.phone && (
SĐT: {item.phone}
)} {item.email && (
Email: {item.email}
)}
{item.description}
); })} {/* Context Menu Chia sẻ */} {shareMenu && (
e.stopPropagation()} > {shareMenu.isParticipant ? ( shareMenu.canShare ? ( ) : (
Bạn đã gia nhập tour này
) ) : shareMenu.hasPendingRequest ? ( ) : ( )}
)} {/* Admin Modal */} setIsAdminModalOpen(false)} /> {/* Create Tour Modal */} setIsCreateModalOpen(false)} onSuccess={(tour) => { // Tự động tạo ghi chú mới cho hành trình vừa tạo const savedNotes = localStorage.getItem('my_journey_notes'); let notes = []; try { notes = savedNotes ? JSON.parse(savedNotes) : []; } catch (e) { notes = []; } const newTourNote = { id: Date.now().toString(), tourId: tour.id, title: `Ghi chú của hành trình: ${tour.title}`, content: `

Bắt đầu lập kế hoạch cho chuyến đi ${tour.title} của bạn tại đây...

`, createdAt: new Date().toISOString() }; localStorage.setItem('my_journey_notes', JSON.stringify([newTourNote, ...notes])); fetchTour(tour.id); onViewTour(tour.id); }} /> {selectedPhoto && ( { setSelectedPhoto(null); setSelectedPhotoGroup([]); const params = new URLSearchParams(window.location.search); if (params.has('photoId')) { params.delete('photoId'); const newSearch = params.toString(); const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}`; window.history.replaceState({}, '', newUrl); } }} photo={selectedPhoto} photoGroup={selectedPhotoGroup} onSelectPhoto={(photo) => { setSelectedPhoto(photo); const params = new URLSearchParams(window.location.search); params.set('photoId', photo.id); window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`); }} onLoginSuccess={onLoginSuccess} onUpdatePhoto={(updatedPhoto) => { setPublicPhotos((prev) => prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p)) ); setSelectedPhoto(updatedPhoto); setSelectedPhotoGroup((prev) => prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p)) ); }} /> )} {/* Floating Trusted Leaderboard Panel (Bottom Left) */}
{isLeaderboardOpen && (

🏆 {t('trustedMembers')}

{trustedUsers.length === 0 ? (

Chưa có thành viên nào được đánh giá.

) : (
{trustedUsers.map((u, idx) => (
{idx + 1}
{u.name}
{u.averageScore} ({u.ratingCount})
))}
)}
)}
{/* Floating Widgets Container (Bottom Right) */}
{/* Recommended Locations Toggle Button */} {/* Blacklist Toggle Button */} {/* Recommendations Panel */} {isRecommendedOpen && (

🌟 {t('recommendedTitle') || 'Đề xuất chất lượng'}

{processedRecommendations.length === 0 ? (

Chưa có địa điểm đề xuất nào.

) : (
{processedRecommendations.map((item) => { const dist = userGpsPos && typeof item.latitude === 'number' && typeof item.longitude === 'number' ? calculateDistance(userGpsPos[0], userGpsPos[1], item.latitude, item.longitude) : null; return (
{item.name} {item.type === 'RESTAURANT' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
{Array.from({ length: item.stars || 5 }).map((_, i) => ( ))}
{item.address && (
{item.address}
)} {item.phone && (
SĐT: {item.phone}
)} {dist !== null && (
Cách đây: {dist < 1 ? `${Math.round(dist * 1000)} m` : `${dist.toFixed(1)} km`}
)} {item.latitude && item.longitude && ( )}
{item.description}
); })}
)}
)} {/* Blacklist Panel */} {isBlacklistOpen && (

⚠️ {t('blacklistTitle')}

{processedBlacklist.length === 0 ? (

{t('emptyBlacklist')}

) : (
{processedBlacklist.map((item) => { const dist = userGpsPos && typeof item.latitude === 'number' && typeof item.longitude === 'number' ? calculateDistance(userGpsPos[0], userGpsPos[1], item.latitude, item.longitude) : null; return (
{item.name} {item.type}
{item.address && (
{item.address}
)} {item.phone && (
SĐT: {item.phone}
)} {dist !== null && (
Cách đây: {dist < 1 ? `${Math.round(dist * 1000)} m` : `${dist.toFixed(1)} km`}
)} {item.latitude && item.longitude && ( )}
Lý do: {item.reason}
); })}
)}
)}
{/* Propose Location Modal */} {isProposeModalOpen && (
setIsProposeModalOpen(false)} />

🌟 Đề xuất địa điểm chất lượng

setProposeForm(prev => ({ ...prev, name: e.target.value }))} className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100" />
setProposeForm(prev => ({ ...prev, phone: e.target.value }))} className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100" />
setProposeForm(prev => ({ ...prev, email: e.target.value }))} className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100" />
setProposeForm(prev => ({ ...prev, address: e.target.value }))} className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100" />
setProposeForm(prev => ({ ...prev, latitude: e.target.value }))} className="w-full px-2 py-1 border border-gray-200 dark:border-slate-800 rounded-lg focus:outline-none text-[11px] bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100" />
setProposeForm(prev => ({ ...prev, longitude: e.target.value }))} className="w-full px-2 py-1 border border-gray-200 dark:border-slate-800 rounded-lg focus:outline-none text-[11px] bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100" />