import React, { useState, useEffect, useMemo, useRef } from 'react'; import { io } from 'socket.io-client'; import { ItineraryTimeline } from '../components/ItineraryTimeline'; import { ExpenseManager } from '../components/ExpenseManager'; import { useTourStore } from '@/store/useTourStore'; import { AddLocationModal } from '@/components/AddLocationModal'; import { AddMemberModal } from '../components/AddMemberModal'; import { useConfirm } from '@/hooks/useConfirm'; import { useNotification } from '@/hooks/useNotification'; import { CommentModal } from '@/components/CommentModal'; import { AddPhotoModal } from '@/components/AddPhotoModal'; import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet'; import _MarkerClusterGroup from 'react-leaflet-cluster'; const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup; import { CoordinateSelectModal } from '../components/CoordinateSelectModal'; import { Map as MapIcon, Wallet, Image as ImageIcon, Upload, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Search, LocateFixed, Loader2, Compass, Car, Bike, Navigation, Footprints, Flag, Clock, Check, X, MessageSquare, Share2, Tag as TagIcon, Trash2, FileText, Edit, Download, Heart } from 'lucide-react'; import L from 'leaflet'; // Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT'; // Định nghĩa kiểu dữ liệu cho OSRM Route interface OSRMRoute { geometry: { coordinates: [number, number][]; // [lng, lat] }; distance: number; // meters duration: number; // seconds legs: { distance: number; duration: number; }[]; // OSRM's internal legs for a route } // Định nghĩa kiểu dữ liệu cho điểm (bao gồm cả userLocation khi được chuyển đổi) interface LocationPoint { latitude: number; longitude: number; id: string; name: string; type: string; legId: string; } // Fix lỗi icon mặc định của Leaflet cho môi trường Vite delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', 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', }); /** * Helper tính khoảng cách giữa 2 tọa độ (Haversine formula) */ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) { const p = 0.017453292519943295; // Math.PI / 180 const c = Math.cos; const a = 0.5 - c((lat2 - lat1) * p) / 2 + c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p)) / 2; return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km } // Helper function to combine segment routes into a single overall route const combineSegmentRoutes = (segmentRoutes: OSRMRoute[][], selectedIndices: number[]): OSRMRoute | null => { if (segmentRoutes.length === 0) return null; let combinedGeometry: [number, number][] = []; let combinedDistance = 0; let combinedDuration = 0; const combinedLegs: { distance: number; duration: number; }[] = []; for (let i = 0; i < segmentRoutes.length; i++) { const segmentIndex = selectedIndices[i] !== undefined ? selectedIndices[i] : 0; // Default to first alternative const chosenRoute = segmentRoutes[i][segmentIndex]; if (!chosenRoute) { // If a segment has no chosen route (e.g., no alternatives or API failed), // we cannot form a complete route. return null; } // Concatenate geometry, avoiding duplicate points at segment junctions if (i > 0 && combinedGeometry.length > 0 && chosenRoute.geometry.coordinates.length > 0) { const lastPointOfPrev = combinedGeometry[combinedGeometry.length - 1]; const firstPointOfCurrent = chosenRoute.geometry.coordinates[0]; // OSRM coordinates are [lng, lat] if (Math.abs(lastPointOfPrev[0] - firstPointOfCurrent[0]) < 1e-6 && Math.abs(lastPointOfPrev[1] - firstPointOfCurrent[1]) < 1e-6) { combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates.slice(1)); } else { combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates); } } else { combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates); } combinedDistance += chosenRoute.distance; combinedDuration += chosenRoute.duration; combinedLegs.push(...chosenRoute.legs); // OSRM legs are sub-segments within a route } return { geometry: { coordinates: combinedGeometry }, distance: combinedDistance, duration: combinedDuration, legs: combinedLegs, }; }; // Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình const MapTourBounds = ({ locations }: { locations: any[] }) => { const map = useMap(); // Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]); useEffect(() => { if (locations.length > 0) { const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude])); if (locations.length === 1) { // Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục) map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true }); } else { map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 }); } } }, [locKey, map]); return null; }; // Component Helper để di chuyển tâm bản đồ về vị trí người dùng khi nhấn nút const RecenterUser = ({ position, trigger }: { position: [number, number] | null, trigger: number }) => { const map = useMap(); useEffect(() => { if (position && trigger > 0) { map.setView(position, 16, { animate: true }); } }, [trigger, position, map]); return null; }; // Component Helper để xử lý xoay bản đồ theo hướng di chuyển const MapRotationHandler = ({ rotation }: { rotation: number }) => { const map = useMap(); // Sử dụng Ref để lưu trữ giá trị xoay cộng dồn, giúp bản đồ luôn xoay theo hướng ngắn nhất // Thay vì nhảy giá trị từ -359 về 0 (làm CSS xoay ngược 1 vòng), ta tính toán delta. const cumulativeRotationRef = useRef(0); const prevRotationRef = useRef(0); useEffect(() => { const container = map.getContainer(); // Đảm bảo tâm xoay luôn ở giữa và kích hoạt tăng tốc phần cứng để giảm lag trên iOS container.style.transformOrigin = 'center center'; container.style.willChange = 'transform'; if (rotation === 0) { cumulativeRotationRef.current = 0; prevRotationRef.current = 0; container.style.transform = `rotate(0deg) scale(1)`; return; } let delta = rotation - prevRotationRef.current; // Chuẩn hóa delta trong khoảng [-180, 180] để tìm hướng xoay gần nhất if (delta > 180) delta -= 360; else if (delta < -180) delta += 360; cumulativeRotationRef.current += delta; prevRotationRef.current = rotation; // Áp dụng transform với giá trị cộng dồn liên tục. // 1. Dùng translateZ(0) để kích hoạt GPU trên iOS. // 2. Giảm scale xuống ~1.6 (vừa đủ che góc) để giảm tải cho bộ nhớ đệm đồ họa. container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.2) translateZ(0)`; container.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1)'; }, [rotation, map]); return null; }; // Component Helper để hiển thị mẹo khi người dùng dừng chuột trên bản đồ quá 3 giây const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => { const [tipPos, setTipPos] = useState(null); const [visible, setVisible] = useState(false); const timerRef = React.useRef(null); useMapEvents({ mousemove: (e) => { if (!canEdit) return; setVisible(false); setTipPos(e.latlng); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { setVisible(true); }, 3000); }, mousedown: () => { if (timerRef.current) clearTimeout(timerRef.current); setVisible(false); }, dragstart: () => { if (timerRef.current) clearTimeout(timerRef.current); setVisible(false); }, contextmenu: () => { if (timerRef.current) clearTimeout(timerRef.current); setVisible(false); } }); if (!visible || !tipPos) return null; return ( Mẹo: nhấn giữ chuột phải để ghim ); }; // Menu ngữ cảnh cho bản đồ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => { const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null); const menuRef = React.useRef(null); // Sử dụng selector để tránh re-render khi mapCenter thay đổi const legs = useTourStore(state => state.legs); const setMapCenter = useTourStore(state => state.setMapCenter); useMapEvents({ contextmenu: (e) => { // Ngăn menu mặc định của trình duyệt hiện lên. if (e.originalEvent) { L.DomEvent.preventDefault(e.originalEvent); L.DomEvent.stopPropagation(e.originalEvent); } console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`); setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng }); }, moveend: (e) => { const map = e.target; const center = map.getCenter(); const zoom = map.getZoom(); const coords: [number, number] = [center.lat, center.lng]; // Chỉ cập nhật store nếu tọa độ thay đổi đáng kể (> 0.0001) để tránh loop const currentStored = useTourStore.getState().mapCenter; const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]); if (diff > 0.0001) { setMapCenter(coords); } localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom })); }, click: () => setMenuPos(null), dragstart: () => setMenuPos(null), }); // Ngăn chặn các sự kiện của bản đồ khi tương tác với menu useEffect(() => { if (menuPos && menuRef.current) { L.DomEvent.disableClickPropagation(menuRef.current); L.DomEvent.disableScrollPropagation(menuRef.current); } }, [menuPos]); if (!menuPos) return null; return (
e.stopPropagation()} onContextMenu={(e) => e.preventDefault()} >
Thêm vào chặng
{legs.map(leg => ( ))}
); }; const getMostLikedPhoto = (photos: any[]) => { if (!photos || photos.length === 0) return null; return [...photos].sort((a, b) => { const likesA = a.metadata?.likedUserIds?.length || 0; const likesB = b.metadata?.likedUserIds?.length || 0; return likesB - likesA; })[0]; }; export const TourDetailPage = ({ onBack, tourId, isPublicView = false, onOpenNotes }: { onBack: () => void, tourId: string, isPublicView?: boolean, onOpenNotes?: () => void }) => { // Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization const currentTour = useTourStore(state => state.currentTour); const legs = useTourStore(state => state.legs); const publicTours = useTourStore(state => state.publicTours); const userRole = useTourStore(state => state.userRole); const mapCenter = useTourStore(state => state.mapCenter); const [userLocation, setUserLocation] = useState<[number, number] | null>(null); // Vị trí hiện tại của người dùng const [gpsHeading, setGpsHeading] = useState(null); // Hướng di chuyển từ GPS const [userSpeed, setUserSpeed] = useState(null); // Tốc độ di chuyển từ GPS // Di chuyển khai báo state lên trên useEffect để tránh lỗi "before initialization" const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan'); const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline'); const [isHeadingMode, setIsHeadingMode] = useState(false); const [mapRotation, setMapRotation] = useState(0); const [isAddLocationOpen, setIsAddLocationOpen] = useState(false); const [isMapFullscreen, setIsMapFullscreen] = useState(false); const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false); // State cho vị trí và hướng của người dùng const [deviceOrientationHeading, setDeviceOrientationHeading] = useState(null); // Hướng thiết bị (la bàn) // Xác định hướng hiển thị của người dùng (ưu tiên hướng di chuyển từ GPS, sau đó đến la bàn thiết bị) const currentHeading = useMemo(() => { if (gpsHeading !== null && userSpeed !== null && userSpeed > 0.5) { return gpsHeading; } else if (deviceOrientationHeading !== null) { return deviceOrientationHeading; } return 0; }, [gpsHeading, userSpeed, deviceOrientationHeading]); // Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render const mapIcons = useMemo(() => ({ start: L.divIcon({ className: '!bg-transparent !border-none', html: `
S
`, iconSize: [28, 28], iconAnchor: [14, 14] }), end: L.divIcon({ className: '!bg-transparent !border-none', html: `
E
`, iconSize: [28, 28], iconAnchor: [14, 14] }), visit: L.divIcon({ className: '!bg-transparent !border-none', html: `
`, iconSize: [20, 20], iconAnchor: [10, 10] }), user: L.divIcon({ className: '!bg-transparent !border-none', html: `
`, iconSize: [32, 32], iconAnchor: [16, 16] }) }), [currentHeading]); useEffect(() => { // Theo dõi vị trí GPS của người dùng (bao gồm hướng và tốc độ khi di chuyển) if (!isPublicView && navigator.geolocation) { const watchId = navigator.geolocation.watchPosition( (pos) => { setUserLocation([pos.coords.latitude, pos.coords.longitude]); setGpsHeading(pos.coords.heading); setUserSpeed(pos.coords.speed); }, (err) => console.warn("Lỗi định vị người dùng:", err), { enableHighAccuracy: true } ); return () => navigator.geolocation.clearWatch(watchId); } else { setGpsHeading(null); setUserSpeed(null); } }, [isPublicView]); // Chỉ phụ thuộc vào isPublicView // State mới cho hướng thiết bị (la bàn) // Theo dõi hướng thiết bị (la bàn) useEffect(() => { const handleOrientation = (event: any) => { let heading: number | null = null; // 1. Đối với iOS: Sử dụng webkitCompassHeading (đã chuẩn hóa hướng Bắc thực) if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) { heading = event.webkitCompassHeading; } // 2. Đối với Android (Chrome): Cần kiểm tra tính tuyệt đối của dữ liệu else if (event.alpha !== null && event.alpha !== undefined) { // Chrome trên Android chỉ cung cấp hướng la bàn chuẩn khi event.absolute là true // hoặc khi nhận từ sự kiện 'deviceorientationabsolute' if (event.absolute === true || event.type === 'deviceorientationabsolute') { // Alpha trên Android tăng theo chiều ngược kim đồng hồ (0=North, 90=West) // Cần chuyển đổi sang chiều kim đồng hồ để khớp với logic quay bản đồ heading = (360 - event.alpha) % 360; } } if (heading !== null) { setDeviceOrientationHeading(heading); } }; // Đăng ký cả hai loại sự kiện để hỗ trợ tối đa các dòng điện thoại window.addEventListener('deviceorientation', handleOrientation, true); window.addEventListener('deviceorientationabsolute', handleOrientation, true); return () => { window.removeEventListener('deviceorientation', handleOrientation, true); window.removeEventListener('deviceorientationabsolute', handleOrientation, true); }; }, []); // Logic kết hợp để xác định hướng xoay bản đồ useEffect(() => { if (!isHeadingMode) { setMapRotation(0); // Đặt lại hướng Bắc nếu chế độ xoay tắt return; } let newRotation: number | null = null; // Ưu tiên hướng GPS nếu có và người dùng đang di chuyển (tốc độ > 0.5 m/s) if (gpsHeading !== null && userSpeed !== null && userSpeed > 0.5) { newRotation = -gpsHeading; } else if (deviceOrientationHeading !== null) { // Nếu không di chuyển hoặc không có hướng GPS, dùng hướng thiết bị newRotation = -deviceOrientationHeading; } if (newRotation !== null) { // Cập nhật ngay lập tức để tăng độ nhạy, CSS transition sẽ lo phần mượt mà setMapRotation(newRotation); } }, [isHeadingMode, gpsHeading, userSpeed, deviceOrientationHeading]); const [isStartPointAction, setIsStartPointAction] = useState(false); const [isEndPointAction, setIsEndPointAction] = useState(false); const [targetLegId, setTargetLegId] = useState(null); const [editingLocation, setEditingLocation] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false); const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState(null); // New state for large photo display const currentUserId = useMemo(() => { const token = localStorage.getItem('token') || localStorage.getItem('guest_token'); if (!token) return null; try { return JSON.parse(atob(token.split('.')[1])).sub; } catch (e) { return null; } }, []); const currentUser = useMemo(() => { const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user'); if (!userStr) return null; try { return JSON.parse(userStr); } catch (e) { return null; } }, []); const [isEditingPhoto, setIsEditingPhoto] = useState(false); const [editPhotoTitle, setEditPhotoTitle] = useState(''); const [editPhotoDescription, setEditPhotoDescription] = useState(''); const [editPhotoLat, setEditPhotoLat] = useState(''); const [editPhotoLng, setEditPhotoLng] = useState(''); const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false); const [isMapOpen, setIsMapOpen] = useState(false); const [resolvedAddress, setResolvedAddress] = useState(''); const [isFullscreen, setIsFullscreen] = useState(false); const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds) ? selectedPhotoForDisplay.metadata.likedUserIds : []; const isPhotoLiked = currentUser && likedUserIds.includes(currentUser.id); const photoLikeCount = likedUserIds.length; const handleToggleLikePhoto = async () => { if (!selectedPhotoForDisplay) return; try { const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}` } }); if (response.ok) { const data = await response.json(); setSelectedPhotoForDisplay((prev: any) => { if (!prev) return null; return { ...prev, metadata: { ...prev.metadata, likedUserIds: data.likedUserIds } }; }); if (currentTour) { fetchTour(currentTour.id); } } } catch (error) { console.error('Error toggling like:', error); } }; useEffect(() => { const lat = selectedPhotoForDisplay?.metadata?.lat; const lng = selectedPhotoForDisplay?.metadata?.lng; if (typeof lat === 'number' && typeof lng === 'number') { setResolvedAddress('Đang xác định địa điểm...'); fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`) .then(res => { if (!res.ok) throw new Error(); return res.json(); }) .then(data => { if (data && data.display_name) { const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim(); setResolvedAddress(shortAddress || data.display_name); } else { setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`); } }) .catch(() => { setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`); }); } else { setResolvedAddress('Chưa xác định tọa độ'); } }, [selectedPhotoForDisplay?.id, selectedPhotoForDisplay?.metadata?.lat, selectedPhotoForDisplay?.metadata?.lng]); useEffect(() => { if (selectedPhotoForDisplay) { setEditPhotoTitle(selectedPhotoForDisplay.metadata?.title || ''); setEditPhotoDescription(selectedPhotoForDisplay.metadata?.description || ''); setEditPhotoLat(selectedPhotoForDisplay.metadata?.lat ?? ''); setEditPhotoLng(selectedPhotoForDisplay.metadata?.lng ?? ''); setIsEditingPhoto(false); } }, [selectedPhotoForDisplay]); const handleSavePhotoEdit = async () => { if (!selectedPhotoForDisplay) return; if (editPhotoLat !== '' && (isNaN(editPhotoLat) || editPhotoLat < -90 || editPhotoLat > 90)) { notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' }); return; } if (editPhotoLng !== '' && (isNaN(editPhotoLng) || editPhotoLng < -180 || editPhotoLng > 180)) { notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' }); return; } setIsSavingPhotoEdit(true); try { const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}` }, body: JSON.stringify({ title: editPhotoTitle, description: editPhotoDescription, latitude: editPhotoLat === '' ? undefined : editPhotoLat, longitude: editPhotoLng === '' ? undefined : editPhotoLng }) }); if (!response.ok) throw new Error('Failed to update photo info'); const updatedPhoto = await response.json(); notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' }); setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null); if (currentTour) { fetchTour(currentTour.id); } setIsEditingPhoto(false); } catch (error) { notify({ title: 'Lỗi', message: 'Không thể cập nhật thông tin ảnh. Vui lòng thử lại.', type: 'error' }); } finally { setIsSavingPhotoEdit(false); } }; const [joinRequests, setJoinRequests] = useState([]); const [titleInput, setTitleInput] = useState(currentTour?.title ?? ''); const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? ''); // State cho input số lượng người tham gia const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0); const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0); const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0); const [tagsInput, setTagsInput] = useState(currentTour?.tags ?? []); const [customTag, setCustomTag] = useState(''); 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 [selectedLegIdForPhoto, setSelectedLegIdForPhoto] = useState('all'); // Keep this state // Memoized filtered photos based on selectedLegIdForPhoto const filteredPhotos = useMemo(() => { if (!currentTour?.photos) return []; let photosToFilter = currentTour.photos; if (selectedLegIdForPhoto !== 'all') { const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto); if (targetLeg) { const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id); // Filter photos that have a locationId and that locationId is in the current leg photosToFilter = photosToFilter.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId)); } else { photosToFilter = []; // If leg not found, no photos } } return photosToFilter; }, [currentTour?.photos, selectedLegIdForPhoto, legs]); // Effect to set initial selected photo for display or reset if current one is no longer in filtered list useEffect(() => { if (filteredPhotos.length > 0 && !selectedPhotoForDisplay) { setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos)); } else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) { setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? getMostLikedPhoto(filteredPhotos) : null); } }, [filteredPhotos, selectedPhotoForDisplay]); const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); const [commentLocationId, setCommentLocationId] = useState(''); const [commentLocationName, setCommentLocationName] = useState(''); const [joinRequestActionId, setJoinRequestActionId] = useState(null); // State cho Modal ghi chú nhanh const [quickNoteLocName, setQuickNoteLocName] = useState(null); const [quickNoteInput, setQuickNoteInput] = useState(''); // Đồng bộ hóa các ô nhập dữ liệu cài đặt với currentTour khi tour tải/cập nhật useEffect(() => { if (currentTour) { setTitleInput(currentTour.title ?? ''); setDescriptionInput(currentTour.description ?? ''); setAdultCountInput(currentTour.adultCount ?? 0); setChildCountInput(currentTour.childCount ?? 0); setChildDiscountInput(currentTour.childDiscount ?? 0); setTagsInput(currentTour.tags ?? []); } }, [currentTour]); // State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [locateTrigger, setLocateTrigger] = useState(0); const [isMapControlsOpen, setIsMapControlsOpen] = useState(false); const [isRoutingLoading, setIsRoutingLoading] = useState(false); const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving'); const [routes, setRoutes] = useState([]); const [selectedRouteIndex, setSelectedRouteIndex] = useState(0); const [routeMenu, setRouteMenu] = useState<{ x: number, y: number, index: number } | null>(null); const [drivingRoute, setDrivingRoute] = useState<[number, number][]>([]); const [segmentDistances, setSegmentDistances] = useState([]); const handleSearchLocation = async (query: string) => { setSearchQuery(query); if (query.trim().length < 2) { setSearchResults([]); return; } setIsSearching(true); try { const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1&namedetails=1&accept-language=vi`); const data = await res.json(); setSearchResults(data); } catch (e) { console.error("Lỗi tìm kiếm:", e); } finally { setIsSearching(false); } }; const handleDeletePhoto = async (photoId: string) => { const isConfirmed = await confirm({ title: 'Xóa ảnh này?', message: 'Bạn có chắc chắn muốn xóa ảnh này khỏi chuyến đi? Hành động này không thể hoàn tác.' }); if (!isConfirmed) return; try { const response = await fetch(`/api/v1/photos/${photoId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}` } }); if (!response.ok) throw new Error('Không thể xóa ảnh'); notify({ title: 'Thành công', message: 'Đã xóa ảnh.', type: 'success' }); fetchTour(tourId); // Re-fetch tour to update photo list setSelectedPhotoForDisplay(null); // Reset selected photo after deletion } catch (error) { notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' }); } }; const fetchTour = useTourStore(state => state.fetchTour); const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action useEffect(() => { if (currentTour) { setTagsInput(currentTour.tags || []); } }, [currentTour]); // Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour const handleCommentIncrement = (locationId: string) => { const currentLegs = useTourStore.getState().legs; const updatedLegs = currentLegs.map(leg => ({ ...leg, locations: leg.locations.map(loc => loc.id === locationId ? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } } : loc ) })); // Cập nhật trực tiếp vào Store useTourStore.setState({ legs: updatedLegs }); }; const handleCommentDecrement = (locationId: string) => { const currentLegs = useTourStore.getState().legs; const updatedLegs = currentLegs.map(leg => ({ ...leg, locations: leg.locations.map(loc => loc.id === locationId ? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } } : loc ) })); useTourStore.setState({ legs: updatedLegs }); }; const fetchPublicTours = useTourStore(state => state.fetchPublicTours); const setMapCenter = useTourStore(state => state.setMapCenter); const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint); const updateTourEndPoint = useTourStore(state => state.updateTourEndPoint); const updateTourDetails = useTourStore(state => state.updateTourDetails); // Thêm action này const initializeLegs = useTourStore(state => state.initializeLegs); const addLocation = useTourStore(state => state.addLocation); const removeMember = useTourStore(state => state.removeMember); const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests); const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest); const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest); const deleteTour = useTourStore(state => state.deleteTour); const confirm = useConfirm(); const notify = useNotification(); // Khôi phục vị trí và mức zoom từ localStorage const [initialViewState] = useState(() => { const saved = localStorage.getItem('map_view_state'); if (saved) { try { return JSON.parse(saved); } catch (e) { return null; } } return null; }); // SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi // Nếu là public view, không có quyền chỉnh sửa const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || ''); const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); const canUploadPhoto = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE'].includes(userRole || ''); const isOwner = isPublicView ? false : userRole === 'OWNER'; const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); useEffect(() => { if (isPublicView) { fetchPublicTourDetails(tourId); } else if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) { fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([])); } // Fetch tour details when tourId changes or public view status changes if (tourId) { isPublicView ? fetchPublicTourDetails(tourId) : fetchTour(tourId); } }, [tourId, isPublicView, userRole]); // Add tourId to dependencies // New useEffect to manage initial photo display when currentTour or legs change useEffect(() => { if (currentTour && currentTour.photos && currentTour.photos.length > 0) { // If there are photos, and no leg is selected, default to 'all' and first photo if (selectedLegIdForPhoto === 'all' && !selectedPhotoForDisplay) { setSelectedPhotoForDisplay(getMostLikedPhoto(currentTour.photos)); } else if (selectedLegIdForPhoto !== 'all') { // If a specific leg is selected, try to find a photo for that leg const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto); if (targetLeg) { const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id); const photosInLeg = currentTour.photos.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId)); if (photosInLeg.length > 0 && !selectedPhotoForDisplay) { setSelectedPhotoForDisplay(getMostLikedPhoto(photosInLeg)); } else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) { setSelectedPhotoForDisplay(photosInLeg.length > 0 ? getMostLikedPhoto(photosInLeg) : null); } } } } }, [currentTour, legs, selectedLegIdForPhoto, selectedPhotoForDisplay]); const [mapZoom] = useState(initialViewState?.zoom || 13); // Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]); // Tạo key định danh cho lộ trình để buộc bản đồ vẽ lại khi dữ liệu thay đổi const routeKey = useMemo(() => JSON.stringify({ locations: allLocations.map(l => ({ id: l.id, lat: l.latitude, lon: l.longitude })), user: userLocation ? { lat: userLocation[0].toFixed(5), lon: userLocation[1].toFixed(5) } : null, travelMode: travelMode, selectedRouteIndex: selectedRouteIndex // Include selectedRouteIndex to force re-render when alternative is chosen }), [allLocations, userLocation, travelMode, selectedRouteIndex]); // Tự động tìm các quãng đường di chuyển thực tế theo phương tiện và vẽ lên bản đồ useEffect(() => { const fetchRoutes = async () => { // Prepare points: userLocation + allLocations const currentPoints: LocationPoint[] = [...allLocations]; if (userLocation) { currentPoints.unshift({ latitude: userLocation[0], longitude: userLocation[1], id: 'user-location', // Dummy ID name: 'Vị trí hiện tại', // Dummy name type: 'MOVE', // Dummy type legId: '', // Dummy legId }); } if (currentPoints.length < 2) { setRoutes([]); setSelectedRouteIndex(0); setDrivingRoute([]); setSegmentDistances([]); return; } // Chèn vị trí người dùng vào đầu danh sách tọa độ nếu có const coordsArray = allLocations.map(loc => `${loc.longitude},${loc.latitude}`); if (userLocation) { coordsArray.unshift(`${userLocation[1]},${userLocation[0]}`); } const coordsString = coordsArray.join(';'); setIsRoutingLoading(true); try { const segmentPromises: Promise[] = []; for (let i = 0; i < currentPoints.length - 1; i++) { const p1 = currentPoints[i]; const p2 = currentPoints[i + 1]; const coordsString = `${p1.longitude},${p1.latitude};${p2.longitude},${p2.latitude}`; segmentPromises.push( fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=3`) // Yêu cầu tối đa 5 phương án thay thế cho mỗi phân đoạn .then(res => { if (!res.ok) throw new Error(`OSRM API error: ${res.status}`); return res.json(); }) .then(data => { if (data.code === 'Ok' && data.routes.length > 0) { return data.routes; // Array of OSRMRoute for this segment } return null; // No routes for this segment }) .catch(error => { console.error(`Lỗi lấy lộ trình cho phân đoạn ${i}-${i+1}:`, error); return null; }) ); } const allSegmentAlternativesRaw = await Promise.all(segmentPromises); const allSegmentAlternatives: OSRMRoute[][] = allSegmentAlternativesRaw.filter((seg): seg is OSRMRoute[] => seg !== null); if (allSegmentAlternatives.length === 0) { setRoutes([]); setSelectedRouteIndex(0); return; } // Now, construct overall alternative routes from segment alternatives // Heuristic: Take the first alternative of each segment to form the primary route. // Then, for subsequent overall alternatives, try different alternatives for the first segment, // keeping other segments at their primary alternative. const overallAlternativeRoutes: OSRMRoute[] = []; // Limit overall alternatives based on the number of alternatives for the first segment, up to 3 const maxOverallAlternativesToGenerate = 3; // Số lượng lộ trình tổng thể muốn tạo // Heuristic để tạo các lộ trình tổng thể đa dạng hơn: // 1. Lộ trình chính (fastest/default cho tất cả các phân đoạn) const primarySegmentIndices = allSegmentAlternatives.map(() => 0); const primaryCombinedRoute = combineSegmentRoutes(allSegmentAlternatives, primarySegmentIndices); if (primaryCombinedRoute) { overallAlternativeRoutes.push(primaryCombinedRoute); } // 2. Các lộ trình thay thế: Thử kết hợp các phương án thay thế từ các phân đoạn // Ví dụ: Lấy phương án thứ N của mỗi phân đoạn (nếu có), hoặc phương án 0 nếu không có for (let altChoice = 1; altChoice < maxOverallAlternativesToGenerate; altChoice++) { const selectedSegmentIndices: number[] = allSegmentAlternatives.map(segmentAlts => Math.min(altChoice, segmentAlts.length - 1) // Chọn phương án thứ 'altChoice', hoặc phương án cuối cùng nếu không đủ ); const combinedRoute = combineSegmentRoutes(allSegmentAlternatives, selectedSegmentIndices); if (combinedRoute && !overallAlternativeRoutes.some(r => JSON.stringify(r.geometry.coordinates) === JSON.stringify(combinedRoute.geometry.coordinates))) { overallAlternativeRoutes.push(combinedRoute); } } setRoutes(overallAlternativeRoutes); setSelectedRouteIndex(0); // Always select the first overall alternative by default } catch (error) { console.error(`Lỗi lấy lộ trình ${travelMode}:`, error); setRoutes([]); } finally { setIsRoutingLoading(false); } }; fetchRoutes(); }, [allLocations, travelMode, userLocation]); // Cập nhật dữ liệu lộ trình hiển thị khi người dùng chọn phương án khác useEffect(() => { if (routes.length > 0 && routes[selectedRouteIndex]) { const route = routes[selectedRouteIndex]; // OSRM trả về [lng, lat], cần đổi sang [lat, lng] cho Leaflet const mappedCoords: [number, number][] = route.geometry.coordinates.map((c: any) => [c[1], c[0]]); setDrivingRoute(mappedCoords); // Lưu quãng đường từng chặng của lộ trình được chọn setSegmentDistances(route.legs.map((leg: any) => leg.distance / 1000)); } }, [selectedRouteIndex, routes]); // Tính toán thông tin hiển thị cho lộ trình đang chọn để đề xuất cho người dùng const selectedRouteInfo = useMemo(() => { if (!routes || routes.length === 0 || !routes[selectedRouteIndex]) return null; const r = routes[selectedRouteIndex]; // Định dạng thời gian di chuyển const duration = r.duration; const hours = Math.floor(duration / 3600); const minutes = Math.round((duration % 3600) / 60); const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`; // Xác định nhãn đề xuất: Index 0 thường là lộ trình tối ưu nhất của OSRM (thông dụng nhất) // Kiểm tra thêm nếu đây là lộ trình ngắn nhất trong các phương án const isShortest = routes.length > 1 && r.distance === Math.min(...routes.map(rt => rt.distance)); let label = "Lộ trình"; if (selectedRouteIndex === 0) label = "Đề xuất"; else if (isShortest) label = "Ngắn nhất"; else label = `Lựa chọn ${selectedRouteIndex + 1}`; return { distance: (r.distance / 1000).toFixed(1), duration: durationStr, label }; }, [routes, selectedRouteIndex]); const handleNavigateToLocation = (location: any) => { setMapCenter([location.latitude, location.longitude]); setLocateTrigger(prev => prev + 1); setIsMapFullscreen(true); notify({ title: 'Bắt đầu chỉ đường', message: `Đang hiển thị lộ trình từ vị trí của bạn đến ${location.name}`, type: 'info' }); }; useEffect(() => { if (initialViewState) { setMapCenter(initialViewState.center); } // This useEffect is for initial load, but we now have tourId prop // The fetching logic is moved to the useEffect above that depends on tourId and isPublicView // So this useEffect can be simplified or removed if its only purpose was initial data load. // if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded // fetchPublicTours(); // } }, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies const handleShare = () => { if (!currentTour) return; // Tạo link với query param ?viewTour=... const shareUrl = `${window.location.origin}?viewTour=${currentTour.id}`; if (navigator.share) { navigator.share({ title: currentTour.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 chia sẻ chuyến đi!', type: 'success' }); }); } else { // Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP) 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 chia sẻ chuyến đi!', type: 'success' }); } catch (err) {} document.body.removeChild(textArea); } }; // Thiết lập kết nối WebSocket Real-time useEffect(() => { if (!currentTour) return; const socket = io(); // Kết nối qua Proxy của Vite (cùng origin) socket.on('connect', () => { socket.emit('joinTour', currentTour.id); }); socket.on('commentAdded', (data: any) => { // Cập nhật UI ngay lập tức khi bất kỳ ai bình luận handleCommentIncrement(data.locationId); }); return () => { socket.disconnect(); }; }, [currentTour?.id]); // This useEffect is for initial demo loading, might not be needed if tourId is always passed // useEffect(() => { // if (publicTours.length > 0 && !currentTour && !isPublicView) { // fetchTour(publicTours[0].id); // } // }, [publicTours, currentTour, fetchTour, isPublicView]); // Hàm xử lý các hành động từ Context Menu của bản đồ const handleMapAction = async (action: string, latlng: L.LatLng) => { // Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng) console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng }); // Đảm bảo có tour và ít nhất một chặng để ghim const currentLegs = useTourStore.getState().legs; if (!currentTour || currentLegs.length === 0) { alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện."); return; } let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu let defaultName = "Địa điểm mới"; let locationType: LocationType = 'VISIT'; // Mặc định là tham quan if (action === 'START') { defaultName = "Điểm bắt đầu"; locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển } if (action === 'END') { targetLegId = currentLegs[currentLegs.length - 1].id; defaultName = "Điểm kết thúc"; locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển } if (action.startsWith('ADD_TO_LEG_')) { targetLegId = action.replace('ADD_TO_LEG_', ''); // Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể } // Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể let detectedName = ""; try { const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`); const data = await res.json(); const addr = data.address; // Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn detectedName = addr.amenity || addr.building || addr.historic || addr.tourist || addr.shop || addr.office || addr.leisure || addr.attraction || addr.road || addr.neighbourhood || addr.suburb || data.display_name?.split(',')[0] || ""; console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`); } catch (e) { console.warn("[FRONTEND] Reverse Geocoding failed:", e); } try { if (action === 'START') { // Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName const resolvedPlaceName = detectedName || "Điểm xuất phát"; console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng); await updateTourStartPoint(currentTour.id, { name: resolvedPlaceName, latitude: latlng.lat, longitude: latlng.lng, }); console.log("[FRONTEND] START point updated successfully."); // Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật // khi store fetch lại dữ liệu tour và re-render. } else if (action === 'END') { // Bước 2: Thiết lập Điểm kết thúc const finalName = detectedName || "Điểm kết thúc"; await updateTourEndPoint(currentTour.id, { name: finalName, latitude: latlng.lat, longitude: latlng.lng, }); } else { // Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName); if (!name) return; await addLocation(currentTour.id, { name, address: '', latitude: latlng.lat, longitude: latlng.lng, legId: targetLegId, type: locationType as any, }); } } catch (error: any) { // Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào) if (error.message.includes('Không tìm thấy chặng')) { alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc."); } else { alert("Đã xảy ra lỗi: " + error.message); } } }; // Đồng bộ tiêu đề ghi chú khi đổi tên chuyến đi const syncTourNoteTitle = (tourId: string, newTitle: string) => { const savedNotes = localStorage.getItem('my_journey_notes'); if (!savedNotes) return; try { let notes = JSON.parse(savedNotes); if (Array.isArray(notes)) { let updated = false; const oldTitle = currentTour?.title || ''; notes = notes.map((n: any) => { if (n.tourId === tourId || n.title === `Ghi chú của hành trình: ${oldTitle}`) { n.tourId = tourId; n.title = `Ghi chú của hành trình: ${newTitle}`; if (oldTitle && n.content) { n.content = n.content.split(`${oldTitle}`).join(`${newTitle}`); } updated = true; } return n; }); if (updated) { localStorage.setItem('my_journey_notes', JSON.stringify(notes)); } } } catch (e) { console.error("Error syncing tour note title:", e); } }; // Hàm xử lý cập nhật số lượng người tham gia const handleUpdateTourInfo = async () => { if (!currentTour) return; try { await updateTourDetails(currentTour.id, { title: titleInput, description: descriptionInput, adultCount: adultCountInput, childCount: childCountInput, childDiscount: childDiscountInput, tags: tagsInput }); notify({ title: 'Thành công', message: 'Đã cập nhật thông tin chuyến đi.', type: 'success' }); if (titleInput !== currentTour.title) { syncTourNoteTitle(currentTour.id, titleInput); } fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan } catch (error: any) { notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' }); } }; // Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour const handleQuickNote = (locationName: string) => { if (isPublicView) return; setQuickNoteLocName(locationName); setQuickNoteInput(''); }; // Hàm xử lý submit ghi chú nhanh từ modal const submitQuickNote = () => { if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return; const content = quickNoteInput; const storedUser = localStorage.getItem('user'); const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' }; const userName = user.name || 'Thành viên'; const now = new Date().toLocaleString('vi-VN'); const noteTitle = `Ghi chú của hành trình: ${currentTour.title}`; const savedNotes = localStorage.getItem('my_journey_notes'); let notes = []; try { notes = savedNotes ? JSON.parse(savedNotes) : []; } catch (e) { notes = []; } let targetNote = notes.find((n: any) => n.tourId === currentTour.id || n.title === noteTitle); // Tạo khối nội dung dạng "Textbox" chuyên nghiệp const newContentLine = `
🕒 ${now} • 👤 ${userName}

📍 ${quickNoteLocName}: ${content}

`; if (targetNote) { targetNote.tourId = currentTour.id; targetNote.title = noteTitle; targetNote.content += newContentLine; } else { const newNote = { id: Date.now().toString(), tourId: currentTour.id, title: noteTitle, content: `

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

` + newContentLine, createdAt: new Date().toISOString() }; notes.unshift(newNote); } localStorage.setItem('my_journey_notes', JSON.stringify(notes)); notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' }); setQuickNoteLocName(null); setQuickNoteInput(''); }; // Hàm xử lý xóa Tour vĩnh viễn const handleDeleteTour = async () => { if (!currentTour) return; const isConfirmed = await confirm({ title: 'Xóa Tour vĩnh viễn?', message: 'Toàn bộ dữ liệu về lộ trình, chi phí và hình ảnh của chuyến đi này sẽ bị xóa bỏ hoàn toàn. Bạn có chắc chắn muốn thực hiện?' }); if (isConfirmed) { try { await deleteTour(currentTour.id); notify({ title: 'Thành công', message: 'Hành trình đã được xóa.', type: 'success' }); onBack(); // Quay về trang khám phá sau khi xóa thành công } catch (error: any) { notify({ title: 'Lỗi', message: error.message || 'Không thể xóa hành trình.', type: 'error' }); } } }; // Ngăn chặn sự kiện click trên menu lộ trình làm ảnh hưởng bản đồ const routeMenuRef = React.useRef(null); useEffect(() => { if (routeMenu && routeMenuRef.current) { L.DomEvent.disableClickPropagation(routeMenuRef.current); } }, [routeMenu]); // Đóng menu lộ trình khi chuyển tab hoặc thay đổi chế độ xem useEffect(() => { setRouteMenu(null); }, [activeTab, viewMode]); // Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính const startPoint = useMemo(() => legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0), [legs] ); const endPoint = useMemo(() => legs.flatMap(l => l.locations).find(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0), [legs] ); // Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store const tabs = [ { id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true }, { id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') }, { id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true }, { id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' }, ].filter(t => t.visible); const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); // Logic tính toán ngày hiển thị: Ưu tiên ngày của Tour, sau đó đến ngày của các Chặng const tourDateDisplay = useMemo(() => { if (currentTour?.startDate && currentTour?.endDate) { return `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}`; } const firstLeg = legs[0]; const lastLeg = legs[legs.length - 1]; const start = firstLeg?.startDate; const end = lastLeg?.endDate || lastLeg?.startDate; if (start && end) { return `${new Date(start).toLocaleDateString('vi-VN')} - ${new Date(end).toLocaleDateString('vi-VN')}`; } else if (start) { return `Từ ${new Date(start).toLocaleDateString('vi-VN')}`; } return "Chưa xác định ngày"; }, [currentTour, legs]); const travelQuotes = [ "Đừng nghe họ nói, hãy tự mình đi xem.", "Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.", "Hành trình ngàn dặm bắt đầu từ một bước chân.", "Đi là để trở về, nhưng với một tâm hồn mới." ]; const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []); const tourInfo = { title: currentTour?.title || "Hành trình khám phá TP.HCM", date: tourDateDisplay, membersCount: currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.length || 0, budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND", coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop" }; return (
{/* Top Navigation Bar */}

{tourInfo.title}

{/* Nút Ghi chú: Chỉ hiển thị cho người dùng đã đăng nhập và không phải view công khai */} {!isPublicView && onOpenNotes && ( )} {/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */} {canShare && ( )}
{/* Tour Header Info */}
Tour Cover

{tourInfo.title}

{/* Nhãn hiển thị ngay dưới Tiêu đề */} {currentTour?.tags && currentTour.tags.length > 0 && (
{currentTour.tags.map((tag: string) => ( {tag} ))}
)} {/* Mô tả hiển thị dưới Nhãn */} {currentTour?.description && (

{currentTour.description}

)} {/* Dòng tóm tắt Lộ trình */}
Lộ trình: Điểm xuất phát: {startPoint?.name || '...'} - Điểm kết thúc: {endPoint?.name || '...'}
{tourInfo.date}
{tourInfo.membersCount} thành viên
{/* Member Avatars Stack */}
{/* Always show participants */} {currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.slice(0, 5).map((p: any) => { const memberName = p.user?.name || p.displayName || 'Thành viên'; return ( ); })} {isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
{req.user?.name?.charAt(0) || '?'}
))} {tourInfo.membersCount > 5 && (
+{tourInfo.membersCount - 5}
)}
{!isPublicView && canInvite && ( )}
{/* Financial Quick-View Widget or Quote */}
hasFinanceAccess && setActiveTab('expense')} className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`} >
{hasFinanceAccess ? ( <>

Tổng chi tiêu hiện tại (Nhấn để xem chi tiết)

{tourInfo.budget}

) : ( // If no finance access or is public view, show quote

"{randomQuote}"

)}
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
{startPoint && (

Điểm xuất phát

{startPoint.name}

)} {endPoint && (

Điểm kết thúc

{endPoint.name}

)}
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
{/* Tab Switcher */}
{tabs.map((tab) => ( ))}
{/* Tab Panels */}
{activeTab === 'plan' && (
{/* View Mode Toggle */}
{viewMode === 'timeline' ? ( { setTargetLegId(legId); setEditingLocation(null); setIsStartPointAction(!!isStart); setIsEndPointAction(!!isEnd); setIsAddLocationOpen(true); }} onEditLocation={(loc) => { setEditingLocation(loc); setTargetLegId(loc.legId); setMapCenter([loc.latitude, loc.longitude]); // Kiểm tra xem địa điểm đang sửa có phải là điểm mốc đặc biệt không (dựa trên timestamp 1970) const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0; const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0; setIsStartPointAction(!!isStart); setIsEndPointAction(!!isEnd); setIsAddLocationOpen(true); }} onQuickNote={(locName: string) => handleQuickNote(locName)} onSuccess={() => fetchTour(tourId)} isPublicView={isPublicView} onNavigate={handleNavigateToLocation} /> ) : (
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */} {!isPublicView && (
handleSearchLocation(e.target.value)} /> {isSearching ? ( ) : searchQuery && ( )} {/* Kết quả tìm kiếm */} {searchResults.length > 0 && (
{searchResults.map((result, idx) => ( ))}
)}
)} {/* Overlay thông tin lộ trình đề xuất (Lộ trình thông dụng nhất) */} {selectedRouteInfo && drivingRoute.length > 0 && (
{selectedRouteInfo.label}
{selectedRouteInfo.distance} km {selectedRouteInfo.duration}
)} {canEdit && !isPublicView && ( <> setRouteMenu(null)} /> )} {/* Tự động đóng khung Start và End khi dữ liệu thay đổi */} {/* Xử lý xoay bản đồ theo Heading */} {/* Xử lý di chuyển tâm bản đồ về phía người dùng */} {/* Hiển thị vị trí hiện tại của người dùng */} {userLocation && (
Bạn đang ở đây
)} {/* Vẽ tất cả lộ trình: Vẽ các đường phụ trước, đường chính sau để hiển thị đè lên trên */} {routes.length > 0 ? ( [...routes] .map((r, i) => ({ data: r, index: i })) .sort((a, b) => { if (a.index === selectedRouteIndex) return 1; if (b.index === selectedRouteIndex) return -1; return 0; }) .map(({ data, index }) => ( [c[1], c[0]])} color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"} weight={index === selectedRouteIndex ? 6 : 12} opacity={index === selectedRouteIndex ? 1 : 0.35} dashArray={index === selectedRouteIndex ? undefined : "15, 15"} smoothFactor={1} eventHandlers={{ click: (e) => { const originalEvent = (e as any).originalEvent; if (originalEvent) L.DomEvent.stopPropagation(originalEvent); setRouteMenu(null); setSelectedRouteIndex(index); }, contextmenu: (e) => { const originalEvent = (e as any).originalEvent; if (originalEvent) { L.DomEvent.stopPropagation(originalEvent); L.DomEvent.preventDefault(originalEvent); // Đánh dấu để MapContextMenu biết đã có Layer xử lý (originalEvent as any)._routeTriggered = true; } setRouteMenu({ x: (e as any).containerPoint.x, y: (e as any).containerPoint.y, index }); }, mouseover: (e) => { if (index !== selectedRouteIndex) { (e.target as L.Polyline).setStyle({ opacity: 0.7, weight: 14, color: '#64748b' }); } }, mouseout: (e) => { if (index !== selectedRouteIndex) { (e.target as L.Polyline).setStyle({ opacity: 0.35, weight: 12, color: '#94a3b8' }); } } }} /> )) ) : allLocations.length > 1 && ( /* Vẽ đường thẳng nét đứt nếu không lấy được dữ liệu lộ trình thực tế */ [l.latitude, l.longitude]) as any} color="#3b82f6" weight={3} dashArray="5, 10" smoothFactor={1.5} /> )} {allLocations.map((loc: any, index: number) => { const isStart = startPoint && startPoint.id === loc.id; const isEnd = endPoint && endPoint.id === loc.id; // Lấy icon tương ứng từ mapIcons memoized const icon = isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit; // Tính quãng đường từ điểm trước đó (A -> B) để hiển thị tại điểm B const prevLoc = index > 0 ? allLocations[index - 1] : null; const distanceToPrev = prevLoc ? calculateDistance(prevLoc.latitude, prevLoc.longitude, loc.latitude, loc.longitude).toFixed(1) : null; const drivingDist = index > 0 ? segmentDistances[index - 1] : null; return (
{loc.name}
{loc.type}
{(drivingDist || (distanceToPrev && distanceToPrev !== "0.0")) && (
{drivingDist ? `${travelMode === 'driving' ? 'Đường ô tô' : travelMode === 'bike' ? 'Đường xe máy' : 'Đường đi bộ'} từ điểm trước` : 'Khoảng cách chim bay'} {drivingDist ? drivingDist.toFixed(1) : distanceToPrev} km
)}
); })}
{/* Menu ngữ cảnh khi click chuột phải vào con đường */} {routeMenu && (
e.stopPropagation()} >
)} {/* Overlay điều khiển trên bản đồ */}
{isMapControlsOpen && (
{/* Nút La bàn / Xoay bản đồ */} {/* Nút Tìm tôi */} {/* Danh sách lộ trình rút gọn */} {routes.length > 0 && (
{routes.map((route, idx) => ( ))}
)}
)} {/* Chỉ báo đang tìm đường */} {isRoutingLoading && (
Đang tìm đường tối ưu...
)}
)}
)} {activeTab === 'expense' && (
)} {activeTab === 'photo' && (
{/* Main container for the new layout */}
{/* Left Column: Leg List */}

Chặng của Tour

{legs.map(leg => ( ))}
{/* Right Column: Large Photo Display */}
{selectedPhotoForDisplay ? (
Selected Tour Photo setIsFullscreen(true)} /> {/* Overlays (Only show when not editing) */} {!isEditingPhoto && ( <> {/* Like (Heart) button overlay */} {/* Delete button overlay (if owner) */} {currentUserId === selectedPhotoForDisplay.uploaderId && ( )} {/* Bottom metadata details gradient panel overlay */}
{selectedPhotoForDisplay.metadata?.title ? (

{selectedPhotoForDisplay.metadata.title}

) : ( Chưa có tiêu đề )} {selectedPhotoForDisplay.metadata?.description ? (

{selectedPhotoForDisplay.metadata.description}

) : ( Chưa có mô tả )}
{/* Edit Button Overlay */} {((!isPublicView && currentUser?.isAdmin) || (currentUserId && currentUserId === selectedPhotoForDisplay.uploaderId)) && ( )}
{/* Additional metadata info inside bottom overlay */}
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
Địa điểm: {resolvedAddress}
Tải lên bởi: {selectedPhotoForDisplay.uploader?.name || 'Thành viên'}
{(currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && selectedPhotoForDisplay.originalUrl && ( Tải ảnh gốc )}
)}
{isEditingPhoto && (

Chỉnh sửa thông tin ảnh

setEditPhotoTitle(e.target.value)} placeholder="Nhập tiêu đề..." className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent" />