import React, { useState, useEffect, useMemo } 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 { 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 } 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 hoặc hướng Bắc const MapRotationHandler = ({ rotation }: { rotation: number }) => { const map = useMap(); useEffect(() => { const container = map.getContainer(); // Xoay container bản đồ và scale nhẹ để tránh lộ khoảng trắng ở các góc khi xoay container.style.transform = `rotate(${rotation}deg) scale(${rotation === 0 ? 1 : 1.2})`; container.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 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 => ( ))}
); }; 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); // Đị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: [16, 16], iconAnchor: [8, 8] }) }), []); useEffect(() => { if (!isPublicView && navigator.geolocation) { const watchId = navigator.geolocation.watchPosition( (pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]), (err) => console.warn("Lỗi định vị người dùng:", err), { enableHighAccuracy: true } ); return () => navigator.geolocation.clearWatch(watchId); } }, [isPublicView]); const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan'); const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline'); const [isAddLocationOpen, setIsAddLocationOpen] = useState(false); const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false); 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'); if (!token) return null; try { return JSON.parse(atob(token.split('.')[1])).sub; } catch (e) { return null; } }, []); 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(filteredPhotos[0]); } else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) { setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? filteredPhotos[0] : null); } }, [filteredPhotos, selectedPhotoForDisplay]); const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); const [commentLocationId, setCommentLocationId] = useState(''); const [commentLocationName, setCommentLocationName] = useState(''); const [joinRequestActionId, setJoinRequestActionId] = useState(null); // 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 [isHeadingMode, setIsHeadingMode] = useState(false); const [mapRotation, setMapRotation] = useState(0); 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')}` } }); 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(currentTour.photos[0]); } 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(photosInLeg[0]); } else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) { setSelectedPhotoForDisplay(photosInLeg.length > 0 ? photosInLeg[0] : 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]); 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); } } }; // 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' }); 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; const content = window.prompt(`Ghi chú nhanh cho địa điểm: ${locationName}`); if (!content || !content.trim()) return; 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.title === noteTitle); // Tạo khối nội dung dạng "Textbox" chuyên nghiệp const newContentLine = `
🕒 ${now} • 👤 ${userName}

📍 ${locationName}: ${content}

`; if (targetNote) { targetNote.content += newContentLine; } else { const newNote = { id: Date.now().toString(), 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' }); }; // 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?.length || 0, budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND", coverImage: currentTour?.photos?.[0]?.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?.slice(0, 5).map((p: any, i: number) => ( ))} {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 && ( // Hide add member button in public view )}
{/* 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} /> ) : (
{/* 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ý di chuyển tâm bản đồ về phía người dùng */} {/* Xử lý xoay bản đồ */} {/* 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 {/* Optional: Add delete button for the large photo */} {currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && ( )}
) : (

Chọn một ảnh để xem chi tiết

)}
{/* Bottom Row: Thumbnails */}

{selectedLegIdForPhoto === 'all' ? 'Tất cả ảnh' : `Ảnh của Chặng ${legs.find(l => l.id === selectedLegIdForPhoto)?.sequence || ''}`}

{filteredPhotos.length > 0 ? (
{filteredPhotos.map((photo: any) => (
setSelectedPhotoForDisplay(photo)} className={`aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border-2 ${ selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500' : 'border-transparent' } hover:border-blue-300 transition-all cursor-pointer`} > Thumbnail
))}
) : (

Chưa có ảnh nào cho chặng này.

)}
)} {activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view

Yêu cầu tham gia

{joinRequests.length} đang chờ
{joinRequests.map((req: any) => (
{req.user?.name?.charAt(0) || '?'}
{req.user?.name || req.userId}
Được mời bởi {req.requestedBy?.name} • {new Date(req.createdAt).toLocaleString('vi-VN')}
{isOwner &&
}
))} {joinRequests.length === 0 && (
Không có yêu cầu tham gia nào đang chờ phê duyệt.
)}
{canEdit && (

Thông tin cơ bản

{isOwner && ( <>
setTitleInput(e.target.value)} className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold" placeholder="Nhập tên chuyến đi..." />